diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 68496595..32898fdd 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -750,7 +750,7 @@ jobs: --operator-image="${OPERATOR_IMAGE}" \ --profile=edge \ --variant=console - - name: Run authenticated Local API cancellation through real Linux processes + - name: Run actual panel client through Local API, Linux logs and cancellation env: QL3_LOCAL_API_CANCELLATION_LIVE: '1' run: | diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 010278d7..7900c4d3 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-433 验收补强(待 Linux 实跑):现有 Local API cancellation 门接入真实面板 TypeScript 客户端,以 loopback HTTP 验证 capability、版本确认、运行列表、真实日志、取消响应丢失后的同请求重试以及重启持久性。v2 报告绑定客户端源摘要并明确不包含浏览器渲染和 Owner 初始化;专项 21/21、后端 1702 pass/2 条件 skip/0 fail,本机 Docker Engine 未恢复,不能声称组合门已通过。详见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。 + - D-433/ADR-0535(现有面板执行管理源码候选):通过可选 `panel.runControl=task_run_v1` 开放逐项 Task start 和确切 Run cancellation,不恢复旧 Cron 写接口。确认前分别展示定时绑定版本与当前任务版本;每次项目 Run 窗口最多 64 条,只显示当前 Task 匹配项,支持显式选择、状态刷新和该次执行首片日志。确认绑定 mutation UUID,结果不明时只显式重试同一请求;关闭或重登后的响应不得继续操作。客户端 14 项含规范 body parser 与新旧 capability 回归,Node 20 面板构建与 240 files / 11,993,647 bytes 裁剪审计通过;全量前端类型诊断与基线同为 41 项,无新增。此切片仍待同源远端 CI/双架构实物,不代表旧面板已完整迁移,见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。 - D-432/ADR-0534(原生 Console 源码候选):修复断开/重连后的异步结果污染。每次连接使用独立内存代次,统一请求与编辑/执行链拒绝旧成功、失败和 finally;旧响应不得重开编辑器、恢复 Secret catalog、覆盖 proof 或在新连接下发出后续请求。16 项针对性回归、完整 Local API 130/130、后端 1683 pass/2 环境 skip/0 fail,静态资产合计 114,042 bytes。断开不撤销已提交的服务端写入,重连须核对 durable 状态;不新增包、后台服务或后端权限。此修复尚未通过同源远端 CI/阶段实物验收,见 [ADR-0534](./adr/ADR-0534-native-console-session-isolation.md)。 diff --git a/docs/adr/ADR-0534-native-console-session-isolation.md b/docs/adr/ADR-0534-native-console-session-isolation.md index eff684a7..eda19fe5 100644 --- a/docs/adr/ADR-0534-native-console-session-isolation.md +++ b/docs/adr/ADR-0534-native-console-session-isolation.md @@ -35,3 +35,5 @@ 上述本地验证不代表修复已进入既有下载产物。D-431 的 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 产物也不包含本修复;本切片必须以自己的 source revision、CI 和阶段归档证据验收。 提交 `03afd7e8` 的独立 Kubernetes deployment 验证通过,但主 CI [run 33799350582](https://github.com/whyour/qinglong/actions/runs/33799350582) 在 CloudNativePG live gate 失败:rollout 已完成,旧 operator Pod 仍为 Terminating,`verifyImageIds` 纳入其空 imageID 后拒绝。`2005cb6b` 单独修正 operator 取样,排除 deletionTimestamp 已设置的 Pod;存活 Pod 仍执行原摘要校验,空集合仍失败,针对性回归 11/11。此本地修复不改写旧 run 的失败结论,须由后续同源 CI 验证。 + +2026-09-04 后续核对:包含上述修复的 `e4ba5d405f55543dd5d3ca432c24648171ebdda4` 主 CI [run 33802395394](https://github.com/whyour/qinglong/actions/runs/33802395394) 中 CloudNativePG live failover job 已成功,因而 operator 取样修复已有远程实测证据;这不改写旧失败 run,也不单独代表整体 CI 或 Console 实物交付完成。 diff --git a/docs/adr/ADR-0535-canonical-panel-run-control.md b/docs/adr/ADR-0535-canonical-panel-run-control.md index c6d3de8b..c13a7ea9 100644 --- a/docs/adr/ADR-0535-canonical-panel-run-control.md +++ b/docs/adr/ADR-0535-canonical-panel-run-control.md @@ -28,3 +28,13 @@ Node 20 legacy migration toolchain 的 production panel build 通过;裁剪包为 240 files / 11,993,647 bytes,仍在 256 files / 13 MiB 边界。全量前端 TypeScript 检查存在 41 项错误;通过 compiler host 读取提交前源码作对比,确认本次没有新增诊断,不将存量类型错误称为通过。 本切片不是完整旧面板迁移:任务/定时创建编辑、脚本、订阅、环境变量、依赖与多用户远程 Web 会话仍未闭合。Local 仍仅 loopback/SSH tunnel;不可将本地 mock 或 unit tests 当成实际 Owner 写入、公开发布或双架构镜像证据。D-431 实物不含本切片,交付必须绑定自己的 source revision。 + +## 真实客户端组合验收(待 Linux 执行) + +已有 `ql3-local-api-cancellation-live-contract` 不再手写面板启动与取消请求,而是在宿主机将实际 `src/utils/qinglong3.ts` 和 `src/components/qinglong3/runControl.ts` 编译为临时测试模块,在同一个隔离 Linux 网络命名空间中使用真实 fetch 连接生产 Local API。记录两个源文件的 SHA-256;TypeScript、VM 适配器与生成模块均不进入产品制品,不新增 workspace package 或生产依赖。 + +验收顺序为未认证请求 401、实际 capability discovery、读取 Task 当前版本、确认前零写入、启动一次、SQLite/PID start identity 核对、运行列表与指定 Run 读取、运行中日志标记、取消响应已被服务端接受后模拟丢失、同一客户端 action 精确重试、进程退出、唯一取消事件与两条 allowed audit、有序重启后读取 cancelled 及原日志。等待仅属于测试驱动,不在页面中增加轮询。 + +报告升级为 `schemaVersion=2`,要求客户端源摘要、日志/重启日志、响应丢失和相同 body 重试事实,以及一次 start POST、两次 cancellation POST。旧 v1 报告不能代替新门;原 Linux、128/256 MiB、64/256 PID、SQLite 完整性和精确事件计数要求保持不变。报告明确固定 `browserRendering=false`、`ownerProvisioning=seeded_fixture`,不能声称已经完成浏览器渲染、真实 Owner 初始化、任务创建或定时配置全链路。 + +本地专项回归 21/21,完整后端 1704 项(1702 pass / 2 条件 skip / 0 fail);Local image 与 18-package boundary audit 均通过。沙箱内完整回归因 loopback `EPERM` 失败后,在允许本机端口的环境完成上述重跑,不将首次失败算作通过。本机 Docker Engine 不可连接,官方启动/恢复尝试未成功,因此新增 Linux 客户端组合门尚未执行;原有 CI amd64/arm64 Local image job 将继续执行此门,不以单元测试替代。 diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index f198e12b..e59d6c54 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -29,6 +29,8 @@ D-431 给原生 Task、Trigger、Run 列表增加页脚“下一页/回到首 D-433 源码候选为旧面板定时行增加“执行管理”:先展示当前 Task revision,再显式确认运行;选择确切 Run 后可以刷新、读该次日志或请求取消。它不恢复旧 Cron 写接口,也不修改定时绑定版本。网络结果不明时只重试同一 mutation,关闭窗口不会撤销已提交请求。此功能需服务端 capability 显式开放,不包含在上表任何旧归档中,见 [ADR-0535](../adr/ADR-0535-canonical-panel-run-control.md)。 +执行管理的后续验收已接入实际面板客户端与 Linux Local API 组合门,要求读取真实进程日志、取消响应丢失后的精确重试以及重启后持久状态/日志;当前仍待 Linux 实跑。该门使用隔离数据库夹具,不是浏览器与 Owner 初始化全链路证明。阶段包可用与“原版 2.x 所有页面可直接替换后端”是不同结论,请按上表的 source revision 选择。 + D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config `,同时绑定外层 API 配置、内层 Application 配置与 exact mounts,但该模式不会启动本页使用的 listener、credential、scheduler 或 mutation surface。正常启动仍使用下文不带 `--cutover-probe` 的命令;提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的 [artifact run 33463415938](https://github.com/whyour/qinglong/actions/runs/33463415938) 已交付 exact amd64/arm64 Console Trial Kit 与 milestone,三份下载产物的离线 auditor 均为 `compatible=true`。 ## 前置条件 diff --git a/scripts/lib/ql3-local-api-cancellation-live-scenario.cjs b/scripts/lib/ql3-local-api-cancellation-live-scenario.cjs index e1502a27..0c549c1d 100644 --- a/scripts/lib/ql3-local-api-cancellation-live-scenario.cjs +++ b/scripts/lib/ql3-local-api-cancellation-live-scenario.cjs @@ -10,6 +10,8 @@ const path = require('node:path'); const { createRequire } = require('node:module'); const { spawn } = require('node:child_process'); const { DatabaseSync } = require('node:sqlite'); +const assert = require('node:assert/strict'); +const { loadPanelClient } = require('./ql3-panel-run-control-live-client.cjs'); const TIMEOUT_MS = 45_000; @@ -188,7 +190,10 @@ function insertTaskAndIdentity( command: { kind: 'argv', file: '/bin/sh', - args: ['-c', 'trap "exit 0" TERM INT; while :; do sleep 1; done'], + args: [ + '-c', + 'trap "exit 0" TERM INT; printf "qinglong3-panel-live-log\\n"; while :; do sleep 1; done', + ], }, }, }, @@ -529,35 +534,44 @@ async function main(argv = process.argv.slice(2)) { () => active.events.some((event) => event.event === 'listening'), 'Local API listener', ); - const startBody = JSON.stringify({ - schema: 'qinglong/task-start@v1', - mutationId: '019f8700-0000-7000-8000-000000000004', - expectedRevision: seeded.definition.revision, - expectedContentDigest: seeded.definition.contentDigest, - }); - const started = await request( + const unauthorized = await request( port, - seeded.token, - '/api/v3/projects/default/tasks/live-cancellation-task/runs', - { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'content-length': String(Buffer.byteLength(startBody)), - }, - body: startBody, - }, + '', + '/api/v3/projects/default/tasks/live-cancellation-task', ); - if (started.statusCode !== 202 || started.body.status !== 'accepted') { - fail(`task start was rejected: ${JSON.stringify(started)}`); - } + assert.equal(unauthorized.statusCode, 401); + const panel = loadPanelClient( + path.join(value.evidenceRoot, 'panel-client'), + port, + ); + const capabilities = await panel.auth.discoverQingLong3( + '/api/v3/capabilities', + ); + assert.equal(capabilities?.panel.runControl, 'task_run_v1'); + assert.equal(panel.auth.setQingLong3Credential(seeded.token), true); + const client = panel.control.createPanelRunControl( + { + ql3: { + projectId: 'default', + taskId: 'live-cancellation-task', + }, + }, + capabilities, + ); + const task = await client.readTask(); + assert.equal(task.revision, seeded.definition.revision); + assert.equal(task.contentDigest, seeded.definition.contentDigest); + const start = client.prepareStart(task); + assert.equal(panel.requests.filter((r) => r.method === 'POST').length, 0); + const started = await start.execute(); + assert.equal(started.status, 'accepted'); const running = await waitFor(() => { const row = query( databasePath, `SELECT run.status, attempt.status AS attemptStatus, attempt.pid FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id WHERE run.id = ?`, - started.body.runId, + started.runId, ); return row?.status === 'running' && row?.attemptStatus === 'running' && @@ -568,47 +582,39 @@ async function main(argv = process.argv.slice(2)) { const taskPid = Number(running.pid); const taskStartTicks = procStartTicks(taskPid); const apiRssBytes = rssBytes(active.child.pid); - const cancellationBody = JSON.stringify({ - schema: 'qinglong/run-cancellation@v1', - mutationId: 'local-live-cancellation-1', - }); - const cancellationPath = `/api/v3/projects/default/runs/${started.body.runId}/cancellation`; - const requestOptions = { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'content-length': String(Buffer.byteLength(cancellationBody)), - }, - body: cancellationBody, - }; - const accepted = await request( - port, - seeded.token, - cancellationPath, - requestOptions, + const page = await client.listRuns(); + assert.equal(page.scanned, 1); + assert.equal(page.runs[0]?.id, started.runId); + let selected; + await waitFor(async () => { + selected = await client.readRun(started.runId); + return (await client.readLog(selected)).includes( + 'qinglong3-panel-live-log', + ); + }, 'panel reads running process log marker'); + assert.equal(selected.status, 'running'); + const cancellation = client.prepareCancel(selected); + panel.loseNextCancellationResponse(); + await assert.rejects( + cancellation.execute(), + (error) => error.uncertain === true, ); - const replay = await request( - port, - seeded.token, - cancellationPath, - requestOptions, + const replay = await cancellation.execute(); + assert.equal(replay.status, 'already_requested'); + assert.equal(replay.runId, started.runId); + const cancelRequests = panel.requests.filter( + (r) => r.method === 'POST' && r.path.endsWith('/cancellation'), ); - if (accepted.statusCode !== 202 || accepted.body.status !== 'accepted') { - fail(`cancellation was rejected: ${JSON.stringify(accepted)}`); - } - if ( - replay.statusCode !== 200 || - replay.body.status !== 'already_requested' - ) { - fail(`cancellation replay drifted: ${JSON.stringify(replay)}`); - } + assert.equal(cancelRequests.length, 2); + assert.equal(cancelRequests[0].body, cancelRequests[1].body); + assert.equal(cancelRequests[0].path, cancelRequests[1].path); const terminal = await waitFor(() => { const row = query( databasePath, `SELECT run.status, attempt.status AS attemptStatus FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id WHERE run.id = ?`, - started.body.runId, + started.runId, ); return row?.status === 'cancelled' && row?.attemptStatus === 'cancelled' ? row @@ -618,23 +624,25 @@ async function main(argv = process.argv.slice(2)) { () => !sameProcessExists(taskPid, taskStartTicks), 'task process identity exit', ); + await waitFor(async () => { + const current = await client.readRun(started.runId); + return (await client.readLog(current)).includes( + 'qinglong3-panel-live-log', + ); + }, 'panel reads actual task log marker'); await stopApi(active); active = startApi(executable, apiConfigPath); await waitFor( () => active.events.some((event) => event.event === 'listening'), 'restarted Local API listener', ); - const observed = await request( - port, - seeded.token, - `/api/v3/projects/default/runs/${started.body.runId}`, + const observed = await client.readRun(started.runId); + assert.equal(observed.status, 'cancelled'); + assert.ok( + (await client.readLog(observed)).includes('qinglong3-panel-live-log'), ); - if ( - observed.statusCode !== 200 || - observed.body.run.status !== 'cancelled' - ) { - fail(`restart observation drifted: ${JSON.stringify(observed)}`); - } + client.dispose(); + panel.auth.clearQingLong3Credential(); await stopApi(active); const facts = query( databasePath, @@ -643,8 +651,8 @@ async function main(argv = process.argv.slice(2)) { (SELECT COUNT(*) FROM RunEvents WHERE run_id = ? AND type = 'run.cancelled') AS cancelledEvents, (SELECT COUNT(*) FROM QingLong3SecurityAuditEvents WHERE operation_id = 'run.cancel' AND outcome = 'allowed') AS cancelAudits, (SELECT integrity_check FROM pragma_integrity_check LIMIT 1) AS integrity`, - started.body.runId, - started.body.runId, + started.runId, + started.runId, ); if ( facts.cancelEvents !== 1 || @@ -654,7 +662,7 @@ async function main(argv = process.argv.slice(2)) { ) fail(`durable facts drifted: ${JSON.stringify(facts)}`); const report = { - schemaVersion: 1, + schemaVersion: 2, profile: value.profile, platform: { os: 'linux', architecture: process.arch, procfs: true }, resourceEnvelope: { @@ -675,6 +683,23 @@ async function main(argv = process.argv.slice(2)) { restartObservedCancelled: true, sqliteIntegrity: facts.integrity, }, + panelClient: { + authSourceSha256: panel.manifest.auth.sourceSha256, + controlSourceSha256: panel.manifest.control.sourceSha256, + unauthenticatedStatus: unauthorized.statusCode, + capabilityDiscovered: true, + runListed: true, + logMarkerObserved: true, + restartLogMarkerObserved: true, + cancellationResponseLost: true, + exactCancellationBodyReplay: true, + startPosts: panel.requests.filter( + (r) => r.method === 'POST' && r.path.endsWith('/runs'), + ).length, + cancellationPosts: cancelRequests.length, + browserRendering: false, + ownerProvisioning: 'seeded_fixture', + }, qualification: { evidenceClass: 'linux_virtualized_live_contract', physicalDevice: false, diff --git a/scripts/lib/ql3-panel-run-control-live-client.cjs b/scripts/lib/ql3-panel-run-control-live-client.cjs new file mode 100644 index 00000000..5122ff14 --- /dev/null +++ b/scripts/lib/ql3-panel-run-control-live-client.cjs @@ -0,0 +1,125 @@ +'use strict'; + +// Test tooling only: execute the shipped panel modules against a real loopback API. +// Neither TypeScript nor this VM adapter belongs in the product runtime artifact. +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const SOURCES = Object.freeze({ + auth: 'src/utils/qinglong3.ts', + control: 'src/components/qinglong3/runControl.ts', +}); +const digest = (value) => + crypto.createHash('sha256').update(value).digest('hex'); + +function preparePanelClient(repository, directory) { + const ts = require('typescript'); + fs.mkdirSync(directory, { mode: 0o700 }); + const manifest = {}; + for (const [name, relative] of Object.entries(SOURCES)) { + const source = fs.readFileSync(path.join(repository, relative), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }, + }).outputText; + fs.writeFileSync(path.join(directory, `${name}.cjs`), compiled, { + flag: 'wx', + mode: 0o600, + }); + manifest[name] = { + source: relative, + sourceSha256: digest(source), + compiledSha256: digest(compiled), + }; + } + fs.writeFileSync( + path.join(directory, 'manifest.json'), + JSON.stringify(manifest), + { + flag: 'wx', + mode: 0o600, + }, + ); + return manifest; +} + +function loadPanelClient(directory, port, transport = fetch) { + assert.ok(Number.isSafeInteger(port) && port > 0 && port < 65536); + const origin = `http://127.0.0.1:${port}`; + const manifest = JSON.parse( + fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'), + ); + const requests = []; + let loseCancellation = false; + const context = vm.createContext({ + TextDecoder, + Uint8Array, + atob, + crypto: crypto.webcrypto, + fetch: async (relative, options) => { + assert.ok( + typeof relative === 'string' && + relative.startsWith('/') && + !relative.startsWith('//'), + ); + const url = new URL(relative, origin); + assert.equal(url.origin, origin); + requests.push({ + path: relative, + method: options.method, + body: options.body, + }); + const response = await transport(url.href, { + ...options, + signal: AbortSignal.timeout(10_000), + }); + if ( + loseCancellation && + options.method === 'POST' && + relative.endsWith('/cancellation') + ) { + loseCancellation = false; + // Consume a real committed response before simulating transport loss. Never fabricate a receipt. + assert.equal(response.status, 202); + const body = await response.json(); + assert.equal(body.status, 'accepted'); + throw new Error('Injected response loss after accepted cancellation'); + } + return response; + }, + }); + const load = (name, imports = {}) => { + assert.equal(manifest[name].source, SOURCES[name]); + assert.match(manifest[name].sourceSha256, /^[a-f0-9]{64}$/); + const code = fs.readFileSync(path.join(directory, `${name}.cjs`), 'utf8'); + assert.equal(digest(code), manifest[name].compiledSha256); + const mod = { exports: {} }; + vm.runInContext(`(function(require,exports,module){${code}\n})`, context)( + (id) => { + assert.ok(Object.hasOwn(imports, id), `Unexpected panel import ${id}`); + return imports[id]; + }, + mod.exports, + mod, + ); + return mod.exports; + }; + const auth = load('auth'); + const control = load('control', { '@/utils/qinglong3': auth }); + return { + auth, + control, + requests, + manifest, + loseNextCancellationResponse() { + loseCancellation = true; + }, + }; +} + +module.exports = { preparePanelClient, loadPanelClient }; diff --git a/scripts/ql3-local-api-cancellation-live-audit.cjs b/scripts/ql3-local-api-cancellation-live-audit.cjs index 765970ed..7f6de580 100644 --- a/scripts/ql3-local-api-cancellation-live-audit.cjs +++ b/scripts/ql3-local-api-cancellation-live-audit.cjs @@ -23,13 +23,14 @@ function validateLocalApiCancellationLiveReport(value) { 'artifact', 'compatible', 'observations', + 'panelClient', 'platform', 'profile', 'qualification', 'resourceEnvelope', 'schemaVersion', ]); - if (!record || value.schemaVersion !== 1 || !PROFILES.has(value.profile)) { + if (!record || value.schemaVersion !== 2 || !PROFILES.has(value.profile)) { return Object.freeze({ compatible: false, findings: ['report identity is invalid'], @@ -105,6 +106,38 @@ function validateLocalApiCancellationLiveReport(value) { observed?.sqliteIntegrity !== 'ok' ) findings.push('API to durable process-stop observations are incomplete'); + const panel = value.panelClient; + if ( + !exactKeys(panel, [ + 'authSourceSha256', + 'controlSourceSha256', + 'unauthenticatedStatus', + 'capabilityDiscovered', + 'runListed', + 'logMarkerObserved', + 'restartLogMarkerObserved', + 'cancellationResponseLost', + 'exactCancellationBodyReplay', + 'startPosts', + 'cancellationPosts', + 'browserRendering', + 'ownerProvisioning', + ]) || + !/^[a-f0-9]{64}$/.test(panel?.authSourceSha256 ?? '') || + !/^[a-f0-9]{64}$/.test(panel?.controlSourceSha256 ?? '') || + panel?.unauthenticatedStatus !== 401 || + panel?.capabilityDiscovered !== true || + panel?.runListed !== true || + panel?.logMarkerObserved !== true || + panel?.restartLogMarkerObserved !== true || + panel?.cancellationResponseLost !== true || + panel?.exactCancellationBodyReplay !== true || + panel?.startPosts !== 1 || + panel?.cancellationPosts !== 2 || + panel?.browserRendering !== false || + panel?.ownerProvisioning !== 'seeded_fixture' + ) + findings.push('actual panel client execution evidence is incomplete'); if ( !exactKeys(value.qualification, [ 'evidenceClass', diff --git a/scripts/ql3-local-api-cancellation-live-contract.cjs b/scripts/ql3-local-api-cancellation-live-contract.cjs index 53e5ed0d..8e9cc7ca 100644 --- a/scripts/ql3-local-api-cancellation-live-contract.cjs +++ b/scripts/ql3-local-api-cancellation-live-contract.cjs @@ -13,6 +13,9 @@ const { } = require('./ql3-local-api-cancellation-live-audit.cjs'); const ROOT = path.resolve(__dirname, '..'); +const { + preparePanelClient, +} = require('./lib/ql3-panel-run-control-live-client.cjs'); const NODE_IMAGE = 'node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d'; @@ -80,6 +83,7 @@ function main(argv = process.argv.slice(2)) { const evidenceRoot = path.join(temporaryRoot, 'evidence'); fs.mkdirSync(evidenceRoot, { mode: 0o700 }); try { + preparePanelClient(ROOT, path.join(evidenceRoot, 'panel-client')); const artifactOutput = run(process.execPath, [ path.join(ROOT, 'scripts/ql3-local-profile-artifact-audit.cjs'), `${selected.profile}-application-api`, @@ -148,7 +152,7 @@ function main(argv = process.argv.slice(2)) { }); process.stdout.write( `${JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, profile: selected.profile, reportWritten: true, compatible: true, diff --git a/test/back/ql3LocalApiCancellationLiveContract.test.cjs b/test/back/ql3LocalApiCancellationLiveContract.test.cjs index 6047c12e..703b532d 100644 --- a/test/back/ql3LocalApiCancellationLiveContract.test.cjs +++ b/test/back/ql3LocalApiCancellationLiveContract.test.cjs @@ -11,7 +11,7 @@ const { function report(profile = 'edge') { return { - schemaVersion: 1, + schemaVersion: 2, profile, platform: { os: 'linux', architecture: 'arm64', procfs: true }, resourceEnvelope: { @@ -36,6 +36,21 @@ function report(profile = 'edge') { physicalDevice: false, passed: true, }, + panelClient: { + authSourceSha256: 'a'.repeat(64), + controlSourceSha256: 'b'.repeat(64), + unauthenticatedStatus: 401, + capabilityDiscovered: true, + runListed: true, + logMarkerObserved: true, + restartLogMarkerObserved: true, + cancellationResponseLost: true, + exactCancellationBodyReplay: true, + startPosts: 1, + cancellationPosts: 2, + browserRendering: false, + ownerProvisioning: 'seeded_fixture', + }, compatible: true, artifact: { profile: `${profile}-application-api`, @@ -121,3 +136,29 @@ test('requires a fresh private report before Docker opt-in is checked', () => { fs.rmSync(root, { recursive: true, force: true }); } }); + +test('rejects old reports and every missing or overstated panel client observation', () => { + const old = report(); + old.schemaVersion = 1; + delete old.panelClient; + assert.equal(validateLocalApiCancellationLiveReport(old).compatible, false); + for (const key of Object.keys(report().panelClient)) { + const missing = report(); + delete missing.panelClient[key]; + assert.equal( + validateLocalApiCancellationLiveReport(missing).compatible, + false, + key, + ); + const invalid = report(); + invalid.panelClient[key] = + typeof invalid.panelClient[key] === 'boolean' + ? !invalid.panelClient[key] + : null; + assert.equal( + validateLocalApiCancellationLiveReport(invalid).compatible, + false, + key, + ); + } +}); diff --git a/test/back/ql3PanelRunControlLiveClient.test.cjs b/test/back/ql3PanelRunControlLiveClient.test.cjs new file mode 100644 index 00000000..3b7d1205 --- /dev/null +++ b/test/back/ql3PanelRunControlLiveClient.test.cjs @@ -0,0 +1,107 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { + preparePanelClient, + loadPanelClient, +} = require('../../scripts/lib/ql3-panel-run-control-live-client.cjs'); + +function fixture(t, transport) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-panel-live-client-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const directory = path.join(root, 'client'); + const manifest = preparePanelClient( + path.resolve(__dirname, '../..'), + directory, + ); + const loaded = loadPanelClient(directory, 12345, transport); + assert.equal( + loaded.auth.setQingLong3Credential(`ql3c_test_${'A'.repeat(43)}`), + true, + ); + return { ...loaded, directory, manifest }; +} + +test('live adapter compiles actual sources and rejects modified generated modules', (t) => { + const f = fixture(t, () => assert.fail('no HTTP expected')); + assert.equal( + f.manifest.control.source, + 'src/components/qinglong3/runControl.ts', + ); + assert.match(f.manifest.control.sourceSha256, /^[a-f0-9]{64}$/); + fs.appendFileSync(path.join(f.directory, 'control.cjs'), '\n// modified'); + assert.throws( + () => loadPanelClient(f.directory, 12345), + /Expected values to be strictly equal/, + ); +}); + +test('live adapter constrains capability discovery to the isolated loopback server', async (t) => { + let calls = 0; + const f = fixture(t, async (url, options) => { + calls++; + assert.equal(url, 'http://127.0.0.1:12345/api/v3/capabilities'); + assert.equal(options.credentials, 'omit'); + assert.equal(options.redirect, 'error'); + return new Response('{}', { status: 404 }); + }); + for (const url of [ + 'https://example.com/', + '//example.com/', + '/\\example.com/', + ]) { + assert.equal(await f.auth.discoverQingLong3(url), null); + } + assert.equal(calls, 0); + assert.equal(await f.auth.discoverQingLong3('/api/v3/capabilities'), null); + assert.equal(calls, 1); +}); + +test('response loss consumes an accepted receipt before actual client retries the same cancellation', async (t) => { + let cancelled = 0; + const f = fixture(t, async (url, options) => { + assert.ok( + url.startsWith('http://127.0.0.1:12345/api/v3/projects/default/'), + ); + if (options.method === 'GET') + return Response.json({ + run: { + id: 'run:a', + projectId: 'default', + taskId: 'task:a', + taskRevision: '1', + status: 'running', + createdAtMs: 1, + }, + }); + cancelled++; + return Response.json( + { + schema: 'qinglong/run-cancellation@v1', + projectId: 'default', + runId: 'run:a', + status: cancelled === 1 ? 'accepted' : 'already_requested', + }, + { status: cancelled === 1 ? 202 : 200 }, + ); + }); + const client = f.control.createPanelRunControl( + { ql3: { projectId: 'default', taskId: 'task:a' } }, + { + panel: { runControl: 'task_run_v1' }, + limits: { logChunkBytes: 16384 }, + }, + ); + const action = client.prepareCancel(await client.readRun('run:a')); + f.loseNextCancellationResponse(); + await assert.rejects(action.execute(), (error) => error.uncertain === true); + assert.equal(cancelled, 1); + assert.equal((await action.execute()).status, 'already_requested'); + const writes = f.requests.filter((r) => r.method === 'POST'); + assert.equal(writes.length, 2); + assert.equal(writes[0].body, writes[1].body); + assert.equal(writes[0].path, writes[1].path); + assert.ok(!JSON.stringify(f.requests).includes('ql3c_')); +});