test(ql3): exercise actual panel client in Linux runtime gate

This commit is contained in:
whyour
2026-09-04 04:49:45 +08:00
parent e4ba5d405f
commit e684a6e64a
11 changed files with 423 additions and 72 deletions
+1 -1
View File
@@ -750,7 +750,7 @@ jobs:
--operator-image="${OPERATOR_IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \
--profile=edge \ --profile=edge \
--variant=console --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: env:
QL3_LOCAL_API_CANCELLATION_LIVE: '1' QL3_LOCAL_API_CANCELLATION_LIVE: '1'
run: | run: |
+2
View File
@@ -1,5 +1,7 @@
# QingLong 3.0 Architecture RFC # 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-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)。 - 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)。
@@ -35,3 +35,5 @@
上述本地验证不代表修复已进入既有下载产物。D-431 的 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 产物也不包含本修复;本切片必须以自己的 source revision、CI 和阶段归档证据验收。 上述本地验证不代表修复已进入既有下载产物。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 验证。 提交 `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 实物交付完成。
@@ -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 读取提交前源码作对比,确认本次没有新增诊断,不将存量类型错误称为通过。 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。 本切片不是完整旧面板迁移:任务/定时创建编辑、脚本、订阅、环境变量、依赖与多用户远程 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-256TypeScript、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 将继续执行此门,不以单元测试替代。
+2
View File
@@ -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)。 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 <local-api.json>`,同时绑定外层 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` D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config <local-api.json>`,同时绑定外层 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`
## 前置条件 ## 前置条件
@@ -10,6 +10,8 @@ const path = require('node:path');
const { createRequire } = require('node:module'); const { createRequire } = require('node:module');
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const { DatabaseSync } = require('node:sqlite'); 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; const TIMEOUT_MS = 45_000;
@@ -188,7 +190,10 @@ function insertTaskAndIdentity(
command: { command: {
kind: 'argv', kind: 'argv',
file: '/bin/sh', 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'), () => active.events.some((event) => event.event === 'listening'),
'Local API listener', 'Local API listener',
); );
const startBody = JSON.stringify({ const unauthorized = await request(
schema: 'qinglong/task-start@v1',
mutationId: '019f8700-0000-7000-8000-000000000004',
expectedRevision: seeded.definition.revision,
expectedContentDigest: seeded.definition.contentDigest,
});
const started = await request(
port, port,
seeded.token, '',
'/api/v3/projects/default/tasks/live-cancellation-task/runs', '/api/v3/projects/default/tasks/live-cancellation-task',
{
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(startBody)),
},
body: startBody,
},
); );
if (started.statusCode !== 202 || started.body.status !== 'accepted') { assert.equal(unauthorized.statusCode, 401);
fail(`task start was rejected: ${JSON.stringify(started)}`); 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 running = await waitFor(() => {
const row = query( const row = query(
databasePath, databasePath,
`SELECT run.status, attempt.status AS attemptStatus, attempt.pid `SELECT run.status, attempt.status AS attemptStatus, attempt.pid
FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id
WHERE run.id = ?`, WHERE run.id = ?`,
started.body.runId, started.runId,
); );
return row?.status === 'running' && return row?.status === 'running' &&
row?.attemptStatus === 'running' && row?.attemptStatus === 'running' &&
@@ -568,47 +582,39 @@ async function main(argv = process.argv.slice(2)) {
const taskPid = Number(running.pid); const taskPid = Number(running.pid);
const taskStartTicks = procStartTicks(taskPid); const taskStartTicks = procStartTicks(taskPid);
const apiRssBytes = rssBytes(active.child.pid); const apiRssBytes = rssBytes(active.child.pid);
const cancellationBody = JSON.stringify({ const page = await client.listRuns();
schema: 'qinglong/run-cancellation@v1', assert.equal(page.scanned, 1);
mutationId: 'local-live-cancellation-1', assert.equal(page.runs[0]?.id, started.runId);
}); let selected;
const cancellationPath = `/api/v3/projects/default/runs/${started.body.runId}/cancellation`; await waitFor(async () => {
const requestOptions = { selected = await client.readRun(started.runId);
method: 'POST', return (await client.readLog(selected)).includes(
headers: { 'qinglong3-panel-live-log',
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(cancellationBody)),
},
body: cancellationBody,
};
const accepted = await request(
port,
seeded.token,
cancellationPath,
requestOptions,
); );
const replay = await request( }, 'panel reads running process log marker');
port, assert.equal(selected.status, 'running');
seeded.token, const cancellation = client.prepareCancel(selected);
cancellationPath, panel.loseNextCancellationResponse();
requestOptions, await assert.rejects(
cancellation.execute(),
(error) => error.uncertain === true,
); );
if (accepted.statusCode !== 202 || accepted.body.status !== 'accepted') { const replay = await cancellation.execute();
fail(`cancellation was rejected: ${JSON.stringify(accepted)}`); assert.equal(replay.status, 'already_requested');
} assert.equal(replay.runId, started.runId);
if ( const cancelRequests = panel.requests.filter(
replay.statusCode !== 200 || (r) => r.method === 'POST' && r.path.endsWith('/cancellation'),
replay.body.status !== 'already_requested' );
) { assert.equal(cancelRequests.length, 2);
fail(`cancellation replay drifted: ${JSON.stringify(replay)}`); assert.equal(cancelRequests[0].body, cancelRequests[1].body);
} assert.equal(cancelRequests[0].path, cancelRequests[1].path);
const terminal = await waitFor(() => { const terminal = await waitFor(() => {
const row = query( const row = query(
databasePath, databasePath,
`SELECT run.status, attempt.status AS attemptStatus `SELECT run.status, attempt.status AS attemptStatus
FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id
WHERE run.id = ?`, WHERE run.id = ?`,
started.body.runId, started.runId,
); );
return row?.status === 'cancelled' && row?.attemptStatus === 'cancelled' return row?.status === 'cancelled' && row?.attemptStatus === 'cancelled'
? row ? row
@@ -618,23 +624,25 @@ async function main(argv = process.argv.slice(2)) {
() => !sameProcessExists(taskPid, taskStartTicks), () => !sameProcessExists(taskPid, taskStartTicks),
'task process identity exit', '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); await stopApi(active);
active = startApi(executable, apiConfigPath); active = startApi(executable, apiConfigPath);
await waitFor( await waitFor(
() => active.events.some((event) => event.event === 'listening'), () => active.events.some((event) => event.event === 'listening'),
'restarted Local API listener', 'restarted Local API listener',
); );
const observed = await request( const observed = await client.readRun(started.runId);
port, assert.equal(observed.status, 'cancelled');
seeded.token, assert.ok(
`/api/v3/projects/default/runs/${started.body.runId}`, (await client.readLog(observed)).includes('qinglong3-panel-live-log'),
); );
if ( client.dispose();
observed.statusCode !== 200 || panel.auth.clearQingLong3Credential();
observed.body.run.status !== 'cancelled'
) {
fail(`restart observation drifted: ${JSON.stringify(observed)}`);
}
await stopApi(active); await stopApi(active);
const facts = query( const facts = query(
databasePath, 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 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 COUNT(*) FROM QingLong3SecurityAuditEvents WHERE operation_id = 'run.cancel' AND outcome = 'allowed') AS cancelAudits,
(SELECT integrity_check FROM pragma_integrity_check LIMIT 1) AS integrity`, (SELECT integrity_check FROM pragma_integrity_check LIMIT 1) AS integrity`,
started.body.runId, started.runId,
started.body.runId, started.runId,
); );
if ( if (
facts.cancelEvents !== 1 || facts.cancelEvents !== 1 ||
@@ -654,7 +662,7 @@ async function main(argv = process.argv.slice(2)) {
) )
fail(`durable facts drifted: ${JSON.stringify(facts)}`); fail(`durable facts drifted: ${JSON.stringify(facts)}`);
const report = { const report = {
schemaVersion: 1, schemaVersion: 2,
profile: value.profile, profile: value.profile,
platform: { os: 'linux', architecture: process.arch, procfs: true }, platform: { os: 'linux', architecture: process.arch, procfs: true },
resourceEnvelope: { resourceEnvelope: {
@@ -675,6 +683,23 @@ async function main(argv = process.argv.slice(2)) {
restartObservedCancelled: true, restartObservedCancelled: true,
sqliteIntegrity: facts.integrity, 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: { qualification: {
evidenceClass: 'linux_virtualized_live_contract', evidenceClass: 'linux_virtualized_live_contract',
physicalDevice: false, physicalDevice: false,
@@ -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 };
@@ -23,13 +23,14 @@ function validateLocalApiCancellationLiveReport(value) {
'artifact', 'artifact',
'compatible', 'compatible',
'observations', 'observations',
'panelClient',
'platform', 'platform',
'profile', 'profile',
'qualification', 'qualification',
'resourceEnvelope', 'resourceEnvelope',
'schemaVersion', 'schemaVersion',
]); ]);
if (!record || value.schemaVersion !== 1 || !PROFILES.has(value.profile)) { if (!record || value.schemaVersion !== 2 || !PROFILES.has(value.profile)) {
return Object.freeze({ return Object.freeze({
compatible: false, compatible: false,
findings: ['report identity is invalid'], findings: ['report identity is invalid'],
@@ -105,6 +106,38 @@ function validateLocalApiCancellationLiveReport(value) {
observed?.sqliteIntegrity !== 'ok' observed?.sqliteIntegrity !== 'ok'
) )
findings.push('API to durable process-stop observations are incomplete'); 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 ( if (
!exactKeys(value.qualification, [ !exactKeys(value.qualification, [
'evidenceClass', 'evidenceClass',
@@ -13,6 +13,9 @@ const {
} = require('./ql3-local-api-cancellation-live-audit.cjs'); } = require('./ql3-local-api-cancellation-live-audit.cjs');
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const {
preparePanelClient,
} = require('./lib/ql3-panel-run-control-live-client.cjs');
const NODE_IMAGE = const NODE_IMAGE =
'node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d'; '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'); const evidenceRoot = path.join(temporaryRoot, 'evidence');
fs.mkdirSync(evidenceRoot, { mode: 0o700 }); fs.mkdirSync(evidenceRoot, { mode: 0o700 });
try { try {
preparePanelClient(ROOT, path.join(evidenceRoot, 'panel-client'));
const artifactOutput = run(process.execPath, [ const artifactOutput = run(process.execPath, [
path.join(ROOT, 'scripts/ql3-local-profile-artifact-audit.cjs'), path.join(ROOT, 'scripts/ql3-local-profile-artifact-audit.cjs'),
`${selected.profile}-application-api`, `${selected.profile}-application-api`,
@@ -148,7 +152,7 @@ function main(argv = process.argv.slice(2)) {
}); });
process.stdout.write( process.stdout.write(
`${JSON.stringify({ `${JSON.stringify({
schemaVersion: 1, schemaVersion: 2,
profile: selected.profile, profile: selected.profile,
reportWritten: true, reportWritten: true,
compatible: true, compatible: true,
@@ -11,7 +11,7 @@ const {
function report(profile = 'edge') { function report(profile = 'edge') {
return { return {
schemaVersion: 1, schemaVersion: 2,
profile, profile,
platform: { os: 'linux', architecture: 'arm64', procfs: true }, platform: { os: 'linux', architecture: 'arm64', procfs: true },
resourceEnvelope: { resourceEnvelope: {
@@ -36,6 +36,21 @@ function report(profile = 'edge') {
physicalDevice: false, physicalDevice: false,
passed: true, 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, compatible: true,
artifact: { artifact: {
profile: `${profile}-application-api`, 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 }); 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,
);
}
});
@@ -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_'));
});