From 0235973c9b54a2f22de09b6487ea9f184f0b8bfd Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 1 Sep 2026 12:18:28 +0800 Subject: [PATCH] fix(ql3): preserve adopted baseline during capture --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 +- ...exact-post-write-reconciliation-capture.md | 2 + .../deployment/reconciliation/stoppedProof.ts | 47 +++++++++++ .../reconciliationCapturePrepare.test.cjs | 79 +++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 8a65f595..83d6bc2f 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,6 +1,6 @@ # QingLong 3.0 Architecture RFC -- D-426c1/ADR-0526(源码与 exact artifact gate 已闭合,双架构阶段实物待交付):downloadable `upgrade-cutover-rehearsal.sh` 保留既有 clean rollback 默认路径,并新增显式 `--capture-after-write <独立私有根>`。同一 reviewed stage/Owner/apply/target-active 链先通过正式 `task.put` 提交固定、无网络/Secret 的业务 Task,再要求 target stop 返回 `reconciliation_required`;随后以现有短生命周期 Operator 串行执行 reconciliation capture prepare/commit/verify,把 legacy、target、recovery、Application config、activation 与 exact stopped head/record 密封到外置 root。成功 summary 固定 `reconciliation_captured`、`legacySource=unchanged`、`target=stopped`、`rollback=not_authorized`、`next=review_required`,不自动回退或应用。Trial Kit/verification/auditor 升为 `@v9/@v7/@v6`、manifest schemaVersion 10,并增加 required `legacyUpgradeReconciliationCapture=passed`;原生 artifact job 必须在保留 clean rollback 演练的同时,用独立目录/容器第二次实跑写后 capture 并验证 manifest/receipt/assets 后才能上传。该切片不修改核心 classifier、不新增 package/依赖/daemon/listener/timer/watcher/连接或稳态资源;低配路由器默认 headless 不变。它证明的是 active target 数据权威经 Owner 产品入口发生写入后的 fail-closed capture,不冒充普通 Local API listener、2.x 老面板、自动 reconciliation、生产升级或 Public Release。 +- D-426c1/ADR-0526(源码与 exact artifact gate 已闭合,双架构阶段实物待交付):downloadable `upgrade-cutover-rehearsal.sh` 保留既有 clean rollback 默认路径,并新增显式 `--capture-after-write <独立私有根>`。同一 reviewed stage/Owner/apply/target-active 链先通过正式 `task.put` 提交固定、无网络/Secret 的业务 Task,再要求 target stop 返回 `reconciliation_required`;随后以现有短生命周期 Operator 串行执行 reconciliation capture prepare/commit/verify,把 legacy、target、recovery、Application config、activation 与 exact stopped head/record 密封到外置 root。对 v4 adopted target,capture proof 必须以 stopped evidence 中的 exact baseline digest 重新加载并核对 durable post-apply baseline,不允许丢失 baseline 维度后再比较。成功 summary 固定 `reconciliation_captured`、`legacySource=unchanged`、`target=stopped`、`rollback=not_authorized`、`next=review_required`,不自动回退或应用。Trial Kit/verification/auditor 升为 `@v9/@v7/@v6`、manifest schemaVersion 10,并增加 required `legacyUpgradeReconciliationCapture=passed`;原生 artifact job 必须在保留 clean rollback 演练的同时,用独立目录/容器第二次实跑写后 capture 并验证 manifest/receipt/assets 后才能上传。该切片不修改核心 classifier、不新增 package/依赖/daemon/listener/timer/watcher/连接或稳态资源;低配路由器默认 headless 不变。它证明的是 active target 数据权威经 Owner 产品入口发生写入后的 fail-closed capture,不冒充普通 Local API listener、2.x 老面板、自动 reconciliation、生产升级或 Public Release。 - D-426b2c/ADR-0525(exact Console 双架构阶段实物已交付):Console adopted target 不再借用 fresh HTTP journey,也不以普通 Local API 启动破坏 clean rollback。`ql3-local-api` 新增显式 `--cutover-probe --config `:严格验证外层 loopback/deployment/Owner 配置后委托既有 Application 只读 probe,不绑定 listener、不读取 credential/pepper、不激活 recovery、scheduler、execution、plugin recovery 或产品管理面。Owner target command 可选绑定 Local API 宿主/容器配置路径,target evidence 同时摘要并校验外层 API、内层 Application、严格不同的 target path、exact read-only mounts 与 `['--cutover-probe','--config',expectedApiPath]`;省略该字段时 headless command/journal digest 不变。Trial Kit cutover summary 升为 v2,绑定 variant 与 `local-application|local-api` entrypoint;原生 workflow 对 headless/Console 均要求 `legacyUpgradeCutover=passed`,同时保留真实 Console listener/API/credential/Task journey 作为独立门。没有新增 workspace package、依赖、daemon、sidecar、timer、watcher、连接池或稳态资源;默认低配路由设备仍选择 headless,Cluster 不复用 Local POSIX/SQLite/Docker proof。Local API 80/80、Owner CLI 314 total/307 pass/7 conditional skip/0 fail、Trial Kit 12/12、Application 56 total/51 pass/5 conditional skip/0 fail,package/Cluster/Edge/image 审计 compatible。提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的普通主 CI run `33462165722` 与 Kubernetes live run `33462165834` 成功;显式 Local Console artifact run `33463415938` 交付 amd64/arm64/milestone(artifact `9784212784`/`9784111987`/`9784288018`),三个重新下载的离线 auditor 均为 `compatible=true`,保留至 2026-10-01。正常 `ql3-local-api --config` 提供现有 3.0 Console,`--cutover-probe` 仅用于无写入的升级证据;这不声明 2.x 老面板 API 零改动兼容。 diff --git a/docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md b/docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md index 1394812d..1239b9a1 100644 --- a/docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md +++ b/docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md @@ -20,6 +20,7 @@ ADR-0524/0525 已让 downloadable Trial Kit 在 headless 与 Console 两种变 5. 成功后生成 `qinglong/local-alpha-upgrade-reconciliation-capture-summary@v1`,只记录 source/architecture/Profile/variant、固定 synthetic Task identity、cutover/capture digest、asset count/bytes 与 `legacySource=unchanged`、`target=stopped`、`rollback=not_authorized`、`next=review_required`。真实 capture 可能包含数据库、配置和 Secret 密文,必须继续保存在操作者指定的 owner-private root,不能上传为普通低敏 CI summary。 6. Trial Kit、verification 与 offline auditor 分别升级为 `qinglong/alpha-local-trial-kit@v9`、verification `@v7` 与 audit `@v6`;manifest schemaVersion 升为 10,并增加 required gate `legacyUpgradeReconciliationCapture=passed`。旧 v8/v6/v5 bundle 不会被新 auditor 静默接受。 7. 原生 amd64/arm64 artifact job 在上传前先保留既有 clean rollback 演练,再从同一 unchanged Legacy fixture、同一 exact bundle 用独立 rehearsal/capture root 与容器名实跑写后 capture。workflow 必须验证 summary、terminal verify、manifest/receipt/assets、legacy 无 WAL/journal,并删除四个 synthetic 容器;任一步失败都不得形成 Local milestone。 +8. exact arm64 首次演练证明 `task.put` 与 `reconciliation_required` 已成功,但暴露原有 capture proof 重算 v4 adopted target 时未带入 target-stop 已验证的 post-apply baseline,因而把同一次 stopped evidence 误报为漂移。修复仅在 persisted stopped evidence 明确携带 `baselineKind=adopted_target` 时加载 durable baseline,并严格核对 Profile、instance/cutover、activation、target path 与 exact baseline digest 后才重算;缺失、篡改、换路径或换 lineage 仍失败关闭,legacy 与 service-manager 路径不变。 ## Profile 与资源边界 @@ -40,6 +41,7 @@ ADR-0524/0525 已让 downloadable Trial Kit 在 headless 与 Console 两种变 - 编辑前 GitNexus:Trial Kit verification/create 为 LOW,offline auditor 为 MEDIUM(5 direct、12 total、0 process),Operator workflow auditor 为 LOW;没有 HIGH/CRITICAL 编辑目标。Shell 模板未被索引,使用 backward-compatible 参数、`sh -n`、静态 contract 与原生 Docker artifact gate 约束。 - 聚焦 bundle/operator 静态回归为 18/18,证明 v9/v7/v6 schema、canonical script、required gate、exact `task.put`/capture command 与 workflow order 闭合。全部 18 个 `packages/ql3-*` 已重新编译并通过自身契约测试;后端脚本层全量回归为 1661 total / 1659 pass / 2 conditional skip / 0 fail。Package boundary(18 个 package、无 single/shallow source package)、Edge import、Cluster dependency 与 Local Operator image audit 全部 `compatible=true`。 +- 首次 exact Console artifact run `33467541901` 在 arm64 上已真实证明写入与 `reconciliation_required`,随后于 capture prepare 因 v4 adopted baseline 重算缺口失败,且未上传 Trial Kit,因而不计为交付。修复后 reconciliation 聚焦回归为 72 total / 70 pass / 2 conditional Docker skip / 0 fail,新增 adopted-baseline 正向与篡改失败关闭用例;Owner CLI 全量为 316 total / 309 pass / 7 conditional skip / 0 fail。仍必须以新 source commit 重跑原生双架构 workflow。 - exact Docker 正向证据必须来自新的 workflow source commit;在该 run 与双架构 milestone 实际成功、重新下载并离线复核前,本 ADR 不宣称 D-426c1 已形成可下载阶段实物。 ## 后续 diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts index cf7de864..5f440be7 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts @@ -1,9 +1,14 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; import { LocalDeploymentConfigurationError } from '../foundation/error'; +import { + adoptedTargetBaselinePath, + readAdoptedTargetBaseline, +} from '../cutover/targetBaseline'; import { cutoverDigest } from '../cutover/targetEvidence'; import { readTargetDataReconciliationEvidenceForPaths, @@ -177,6 +182,11 @@ export function proveLocalReconciliationStoppedState( command.request.stoppedAuthority === 'docker' ? dockerStoppedEvidence(command) : (serviceManagerStoppedRecord(command), undefined); + const adoptedTargetBaseline = + persisted?.baselineKind === 'adopted_target' && + persisted.baselineDigest !== undefined + ? readAdoptedTargetBaselineProjection(command, persisted.baselineDigest) + : undefined; const current = readTargetDataReconciliationEvidenceForPaths( { profile: command.request.profile, @@ -184,6 +194,7 @@ export function proveLocalReconciliationStoppedState( legacySourcePath: command.request.legacySourcePath, targetDatabasePath: command.request.targetDatabasePath, expectedActivationDigest: command.request.expectedActivationDigest, + ...(adoptedTargetBaseline === undefined ? {} : { adoptedTargetBaseline }), }, uid, ); @@ -203,3 +214,39 @@ export function proveLocalReconciliationStoppedState( }); return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) }); } + +function readAdoptedTargetBaselineProjection( + command: Readonly, + expectedBaselineDigest: string, +): Readonly<{ + baselineDigest: string; + targetDevice: string; + targetInode: string; + targetSha256: string; +}> { + const baseline = readAdoptedTargetBaseline( + adoptedTargetBaselinePath(command.options.deploymentRoot), + ); + const targetPathDigest = crypto + .createHash('sha256') + .update(command.request.targetDatabasePath, 'utf8') + .digest('hex'); + if ( + baseline.profile !== command.request.profile || + baseline.instanceId !== command.request.instanceId || + baseline.cutoverId !== command.request.cutoverId || + baseline.activationDigest !== command.request.expectedActivationDigest || + baseline.targetPathDigest !== targetPathDigest || + baseline.baselineDigest !== expectedBaselineDigest + ) { + configurationError( + 'adopted target baseline is detached from stopped evidence', + ); + } + return Object.freeze({ + baselineDigest: baseline.baselineDigest, + targetDevice: baseline.targetDevice, + targetInode: baseline.targetInode, + targetSha256: baseline.targetSha256, + }); +} diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 2f3492f6..3b979950 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -66,6 +66,10 @@ const { const { readTargetDataReconciliationEvidenceForPaths, } = require('../dist/deployment/cutover/targetDataEvidence.js'); +const { + adoptedTargetBaselinePath, + createAdoptedTargetBaseline, +} = require('../dist/deployment/cutover/targetBaseline.js'); const { targetRunJournalRecord, targetStopPhasePath, @@ -121,6 +125,7 @@ function fixture( stoppedAuthority = 'docker', profile = 'edge', createDefaultSidecars = true, + useAdoptedTargetBaseline = false, initializeDatabases, mutateTarget, } = {}, @@ -323,6 +328,32 @@ function fixture( }; const applicationContents = `${JSON.stringify(application, null, 2)}\n`; fs.writeFileSync(applicationConfigPath, applicationContents, { mode: 0o600 }); + let adoptedTargetBaseline; + if (useAdoptedTargetBaseline) { + adoptedTargetBaseline = createAdoptedTargetBaseline({ + preparedAtMs: 1_350, + profile, + instanceId: 'edge-router-1', + cutoverId, + activationDigest, + commitmentDigest, + applicationConfigDigest: crypto + .createHash('sha256') + .update(applicationContents, 'utf8') + .digest('hex'), + legacyDataApplicationCommitDigest: dataCommit.commitDigest, + legacyDataApplicationReceiptDigest: dataCommit.receiptDigest, + targetPathDigest: activationPayload.targetPathDigest, + targetDevice: activationPayload.targetDevice, + targetInode: activationPayload.targetInode, + targetSha256: activationPayload.targetSha256, + }); + fs.writeFileSync( + adoptedTargetBaselinePath(deploymentRoot), + `${JSON.stringify(adoptedTargetBaseline)}\n`, + { mode: 0o600 }, + ); + } const adoptedBundlePayload = { schemaVersion: 1, kind: 'qinglong3-local-adopted-deployment-bundle', @@ -397,6 +428,16 @@ function fixture( legacySourcePath, targetDatabasePath, expectedActivationDigest: activationDigest, + ...(adoptedTargetBaseline === undefined + ? {} + : { + adoptedTargetBaseline: { + baselineDigest: adoptedTargetBaseline.baselineDigest, + targetDevice: adoptedTargetBaseline.targetDevice, + targetInode: adoptedTargetBaseline.targetInode, + targetSha256: adoptedTargetBaseline.targetSha256, + }, + }), }, uid, ); @@ -514,6 +555,8 @@ function fixture( legacySourcePath, targetDatabasePath, recoveryPath, + adoptedTargetBaseline, + reconciliation, }; } @@ -587,6 +630,42 @@ test('capture prepare rejects rollback-candidate stopped data', (t) => { ); }); +test('capture prepare preserves exact adopted-target baseline evidence', (t) => { + const state = fixture(t, { + createDefaultSidecars: false, + useAdoptedTargetBaseline: true, + }); + assert.ok(state.adoptedTargetBaseline); + assert.equal(state.reconciliation.baselineKind, 'adopted_target'); + assert.equal( + state.reconciliation.baselineDigest, + state.adoptedTargetBaseline.baselineDigest, + ); + assert.equal(state.reconciliation.targetMatchesBaseline, false); + const prepared = prepareLocalReconciliationCapture(state.command); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.state, 'reconciliation_capture_prepared'); +}); + +test('capture prepare rejects a valid but detached adopted-target baseline', (t) => { + const state = fixture(t, { + createDefaultSidecars: false, + useAdoptedTargetBaseline: true, + }); + const baselinePath = adoptedTargetBaselinePath(state.deploymentRoot); + const detachedBaseline = createAdoptedTargetBaseline({ + ...state.adoptedTargetBaseline, + targetSha256: 'f'.repeat(64), + }); + fs.writeFileSync(baselinePath, `${JSON.stringify(detachedBaseline)}\n`, { + mode: 0o600, + }); + assert.throws( + () => prepareLocalReconciliationCapture(state.command), + /adopted target baseline is detached from stopped evidence/, + ); +}); + test('service-manager stopped authority uses the same capture fence', (t) => { const state = fixture(t, { stoppedAuthority: 'service-manager' }); const prepared = prepareLocalReconciliationCapture(state.command);