mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
test(ql3): exercise actual panel client in Linux runtime gate
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 实物交付完成。
|
||||
|
||||
@@ -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 将继续执行此门,不以单元测试替代。
|
||||
|
||||
@@ -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 <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 { 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,
|
||||
|
||||
@@ -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',
|
||||
'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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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_'));
|
||||
});
|
||||
Reference in New Issue
Block a user