fix(ql3): preserve adopted baseline during capture

This commit is contained in:
whyour
2026-09-01 12:18:28 +08:00
parent 367ec49e4a
commit 0235973c9b
4 changed files with 129 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# QingLong 3.0 Architecture RFC # 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 targetcapture 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-0525exact Console 双架构阶段实物已交付):Console adopted target 不再借用 fresh HTTP journey,也不以普通 Local API 启动破坏 clean rollback。`ql3-local-api` 新增显式 `--cutover-probe --config <outer-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、连接池或稳态资源;默认低配路由设备仍选择 headlessCluster 不复用 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 failpackage/Cluster/Edge/image 审计 compatible。提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的普通主 CI run `33462165722` 与 Kubernetes live run `33462165834` 成功;显式 Local Console artifact run `33463415938` 交付 amd64/arm64/milestoneartifact `9784212784`/`9784111987`/`9784288018`),三个重新下载的离线 auditor 均为 `compatible=true`,保留至 2026-10-01。正常 `ql3-local-api --config` 提供现有 3.0 Console`--cutover-probe` 仅用于无写入的升级证据;这不声明 2.x 老面板 API 零改动兼容。 - D-426b2c/ADR-0525exact Console 双架构阶段实物已交付):Console adopted target 不再借用 fresh HTTP journey,也不以普通 Local API 启动破坏 clean rollback。`ql3-local-api` 新增显式 `--cutover-probe --config <outer-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、连接池或稳态资源;默认低配路由设备仍选择 headlessCluster 不复用 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 failpackage/Cluster/Edge/image 审计 compatible。提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的普通主 CI run `33462165722` 与 Kubernetes live run `33462165834` 成功;显式 Local Console artifact run `33463415938` 交付 amd64/arm64/milestoneartifact `9784212784`/`9784111987`/`9784288018`),三个重新下载的离线 auditor 均为 `compatible=true`,保留至 2026-10-01。正常 `ql3-local-api --config` 提供现有 3.0 Console`--cutover-probe` 仅用于无写入的升级证据;这不声明 2.x 老面板 API 零改动兼容。
@@ -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。 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 静默接受。 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。 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 与资源边界 ## Profile 与资源边界
@@ -40,6 +41,7 @@ ADR-0524/0525 已让 downloadable Trial Kit 在 headless 与 Console 两种变
- 编辑前 GitNexusTrial Kit verification/create 为 LOWoffline auditor 为 MEDIUM5 direct、12 total、0 process),Operator workflow auditor 为 LOW;没有 HIGH/CRITICAL 编辑目标。Shell 模板未被索引,使用 backward-compatible 参数、`sh -n`、静态 contract 与原生 Docker artifact gate 约束。 - 编辑前 GitNexusTrial Kit verification/create 为 LOWoffline auditor 为 MEDIUM5 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 boundary18 个 package、无 single/shallow source package)、Edge import、Cluster dependency 与 Local Operator image audit 全部 `compatible=true` - 聚焦 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 boundary18 个 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 已形成可下载阶段实物。 - exact Docker 正向证据必须来自新的 workflow source commit;在该 run 与双架构 milestone 实际成功、重新下载并离线复核前,本 ADR 不宣称 D-426c1 已形成可下载阶段实物。
## 后续 ## 后续
@@ -1,9 +1,14 @@
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../foundation/error'; import { LocalDeploymentConfigurationError } from '../foundation/error';
import {
adoptedTargetBaselinePath,
readAdoptedTargetBaseline,
} from '../cutover/targetBaseline';
import { cutoverDigest } from '../cutover/targetEvidence'; import { cutoverDigest } from '../cutover/targetEvidence';
import { import {
readTargetDataReconciliationEvidenceForPaths, readTargetDataReconciliationEvidenceForPaths,
@@ -177,6 +182,11 @@ export function proveLocalReconciliationStoppedState(
command.request.stoppedAuthority === 'docker' command.request.stoppedAuthority === 'docker'
? dockerStoppedEvidence(command) ? dockerStoppedEvidence(command)
: (serviceManagerStoppedRecord(command), undefined); : (serviceManagerStoppedRecord(command), undefined);
const adoptedTargetBaseline =
persisted?.baselineKind === 'adopted_target' &&
persisted.baselineDigest !== undefined
? readAdoptedTargetBaselineProjection(command, persisted.baselineDigest)
: undefined;
const current = readTargetDataReconciliationEvidenceForPaths( const current = readTargetDataReconciliationEvidenceForPaths(
{ {
profile: command.request.profile, profile: command.request.profile,
@@ -184,6 +194,7 @@ export function proveLocalReconciliationStoppedState(
legacySourcePath: command.request.legacySourcePath, legacySourcePath: command.request.legacySourcePath,
targetDatabasePath: command.request.targetDatabasePath, targetDatabasePath: command.request.targetDatabasePath,
expectedActivationDigest: command.request.expectedActivationDigest, expectedActivationDigest: command.request.expectedActivationDigest,
...(adoptedTargetBaseline === undefined ? {} : { adoptedTargetBaseline }),
}, },
uid, uid,
); );
@@ -203,3 +214,39 @@ export function proveLocalReconciliationStoppedState(
}); });
return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) }); return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) });
} }
function readAdoptedTargetBaselineProjection(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
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,
});
}
@@ -66,6 +66,10 @@ const {
const { const {
readTargetDataReconciliationEvidenceForPaths, readTargetDataReconciliationEvidenceForPaths,
} = require('../dist/deployment/cutover/targetDataEvidence.js'); } = require('../dist/deployment/cutover/targetDataEvidence.js');
const {
adoptedTargetBaselinePath,
createAdoptedTargetBaseline,
} = require('../dist/deployment/cutover/targetBaseline.js');
const { const {
targetRunJournalRecord, targetRunJournalRecord,
targetStopPhasePath, targetStopPhasePath,
@@ -121,6 +125,7 @@ function fixture(
stoppedAuthority = 'docker', stoppedAuthority = 'docker',
profile = 'edge', profile = 'edge',
createDefaultSidecars = true, createDefaultSidecars = true,
useAdoptedTargetBaseline = false,
initializeDatabases, initializeDatabases,
mutateTarget, mutateTarget,
} = {}, } = {},
@@ -323,6 +328,32 @@ function fixture(
}; };
const applicationContents = `${JSON.stringify(application, null, 2)}\n`; const applicationContents = `${JSON.stringify(application, null, 2)}\n`;
fs.writeFileSync(applicationConfigPath, applicationContents, { mode: 0o600 }); 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 = { const adoptedBundlePayload = {
schemaVersion: 1, schemaVersion: 1,
kind: 'qinglong3-local-adopted-deployment-bundle', kind: 'qinglong3-local-adopted-deployment-bundle',
@@ -397,6 +428,16 @@ function fixture(
legacySourcePath, legacySourcePath,
targetDatabasePath, targetDatabasePath,
expectedActivationDigest: activationDigest, expectedActivationDigest: activationDigest,
...(adoptedTargetBaseline === undefined
? {}
: {
adoptedTargetBaseline: {
baselineDigest: adoptedTargetBaseline.baselineDigest,
targetDevice: adoptedTargetBaseline.targetDevice,
targetInode: adoptedTargetBaseline.targetInode,
targetSha256: adoptedTargetBaseline.targetSha256,
},
}),
}, },
uid, uid,
); );
@@ -514,6 +555,8 @@ function fixture(
legacySourcePath, legacySourcePath,
targetDatabasePath, targetDatabasePath,
recoveryPath, 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) => { test('service-manager stopped authority uses the same capture fence', (t) => {
const state = fixture(t, { stoppedAuthority: 'service-manager' }); const state = fixture(t, { stoppedAuthority: 'service-manager' });
const prepared = prepareLocalReconciliationCapture(state.command); const prepared = prepareLocalReconciliationCapture(state.command);