Skip to content

Commit 0235973

Browse files
committed
fix(ql3): preserve adopted baseline during capture
1 parent 367ec49 commit 0235973

4 files changed

Lines changed: 129 additions & 1 deletion

File tree

docs/QINGLONG_3_0_ARCHITECTURE_RFC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# QingLong 3.0 Architecture RFC
22

3-
- 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。
3+
- 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。
44

55
- D-426b2c/ADR-0525(exact 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、连接池或稳态资源;默认低配路由设备仍选择 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 零改动兼容。
66

docs/adr/ADR-0526-exact-post-write-reconciliation-capture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ADR-0524/0525 已让 downloadable Trial Kit 在 headless 与 Console 两种变
2020
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。
2121
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 静默接受。
2222
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。
23+
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 路径不变。
2324

2425
## Profile 与资源边界
2526

@@ -40,6 +41,7 @@ ADR-0524/0525 已让 downloadable Trial Kit 在 headless 与 Console 两种变
4041

4142
- 编辑前 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 约束。
4243
- 聚焦 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`
44+
- 首次 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。
4345
- exact Docker 正向证据必须来自新的 workflow source commit;在该 run 与双架构 milestone 实际成功、重新下载并离线复核前,本 ADR 不宣称 D-426c1 已形成可下载阶段实物。
4446

4547
## 后续

packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import crypto from 'node:crypto';
12
import fs from 'node:fs';
23
import path from 'node:path';
34

45
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
56

67
import { LocalDeploymentConfigurationError } from '../foundation/error';
8+
import {
9+
adoptedTargetBaselinePath,
10+
readAdoptedTargetBaseline,
11+
} from '../cutover/targetBaseline';
712
import { cutoverDigest } from '../cutover/targetEvidence';
813
import {
914
readTargetDataReconciliationEvidenceForPaths,
@@ -177,13 +182,19 @@ export function proveLocalReconciliationStoppedState(
177182
command.request.stoppedAuthority === 'docker'
178183
? dockerStoppedEvidence(command)
179184
: (serviceManagerStoppedRecord(command), undefined);
185+
const adoptedTargetBaseline =
186+
persisted?.baselineKind === 'adopted_target' &&
187+
persisted.baselineDigest !== undefined
188+
? readAdoptedTargetBaselineProjection(command, persisted.baselineDigest)
189+
: undefined;
180190
const current = readTargetDataReconciliationEvidenceForPaths(
181191
{
182192
profile: command.request.profile,
183193
activationPath: command.request.activationPath,
184194
legacySourcePath: command.request.legacySourcePath,
185195
targetDatabasePath: command.request.targetDatabasePath,
186196
expectedActivationDigest: command.request.expectedActivationDigest,
197+
...(adoptedTargetBaseline === undefined ? {} : { adoptedTargetBaseline }),
187198
},
188199
uid,
189200
);
@@ -203,3 +214,39 @@ export function proveLocalReconciliationStoppedState(
203214
});
204215
return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) });
205216
}
217+
218+
function readAdoptedTargetBaselineProjection(
219+
command: Readonly<LocalReconciliationCapturePrepareCommand>,
220+
expectedBaselineDigest: string,
221+
): Readonly<{
222+
baselineDigest: string;
223+
targetDevice: string;
224+
targetInode: string;
225+
targetSha256: string;
226+
}> {
227+
const baseline = readAdoptedTargetBaseline(
228+
adoptedTargetBaselinePath(command.options.deploymentRoot),
229+
);
230+
const targetPathDigest = crypto
231+
.createHash('sha256')
232+
.update(command.request.targetDatabasePath, 'utf8')
233+
.digest('hex');
234+
if (
235+
baseline.profile !== command.request.profile ||
236+
baseline.instanceId !== command.request.instanceId ||
237+
baseline.cutoverId !== command.request.cutoverId ||
238+
baseline.activationDigest !== command.request.expectedActivationDigest ||
239+
baseline.targetPathDigest !== targetPathDigest ||
240+
baseline.baselineDigest !== expectedBaselineDigest
241+
) {
242+
configurationError(
243+
'adopted target baseline is detached from stopped evidence',
244+
);
245+
}
246+
return Object.freeze({
247+
baselineDigest: baseline.baselineDigest,
248+
targetDevice: baseline.targetDevice,
249+
targetInode: baseline.targetInode,
250+
targetSha256: baseline.targetSha256,
251+
});
252+
}

packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ const {
6666
const {
6767
readTargetDataReconciliationEvidenceForPaths,
6868
} = require('../dist/deployment/cutover/targetDataEvidence.js');
69+
const {
70+
adoptedTargetBaselinePath,
71+
createAdoptedTargetBaseline,
72+
} = require('../dist/deployment/cutover/targetBaseline.js');
6973
const {
7074
targetRunJournalRecord,
7175
targetStopPhasePath,
@@ -121,6 +125,7 @@ function fixture(
121125
stoppedAuthority = 'docker',
122126
profile = 'edge',
123127
createDefaultSidecars = true,
128+
useAdoptedTargetBaseline = false,
124129
initializeDatabases,
125130
mutateTarget,
126131
} = {},
@@ -323,6 +328,32 @@ function fixture(
323328
};
324329
const applicationContents = `${JSON.stringify(application, null, 2)}\n`;
325330
fs.writeFileSync(applicationConfigPath, applicationContents, { mode: 0o600 });
331+
let adoptedTargetBaseline;
332+
if (useAdoptedTargetBaseline) {
333+
adoptedTargetBaseline = createAdoptedTargetBaseline({
334+
preparedAtMs: 1_350,
335+
profile,
336+
instanceId: 'edge-router-1',
337+
cutoverId,
338+
activationDigest,
339+
commitmentDigest,
340+
applicationConfigDigest: crypto
341+
.createHash('sha256')
342+
.update(applicationContents, 'utf8')
343+
.digest('hex'),
344+
legacyDataApplicationCommitDigest: dataCommit.commitDigest,
345+
legacyDataApplicationReceiptDigest: dataCommit.receiptDigest,
346+
targetPathDigest: activationPayload.targetPathDigest,
347+
targetDevice: activationPayload.targetDevice,
348+
targetInode: activationPayload.targetInode,
349+
targetSha256: activationPayload.targetSha256,
350+
});
351+
fs.writeFileSync(
352+
adoptedTargetBaselinePath(deploymentRoot),
353+
`${JSON.stringify(adoptedTargetBaseline)}\n`,
354+
{ mode: 0o600 },
355+
);
356+
}
326357
const adoptedBundlePayload = {
327358
schemaVersion: 1,
328359
kind: 'qinglong3-local-adopted-deployment-bundle',
@@ -397,6 +428,16 @@ function fixture(
397428
legacySourcePath,
398429
targetDatabasePath,
399430
expectedActivationDigest: activationDigest,
431+
...(adoptedTargetBaseline === undefined
432+
? {}
433+
: {
434+
adoptedTargetBaseline: {
435+
baselineDigest: adoptedTargetBaseline.baselineDigest,
436+
targetDevice: adoptedTargetBaseline.targetDevice,
437+
targetInode: adoptedTargetBaseline.targetInode,
438+
targetSha256: adoptedTargetBaseline.targetSha256,
439+
},
440+
}),
400441
},
401442
uid,
402443
);
@@ -514,6 +555,8 @@ function fixture(
514555
legacySourcePath,
515556
targetDatabasePath,
516557
recoveryPath,
558+
adoptedTargetBaseline,
559+
reconciliation,
517560
};
518561
}
519562

@@ -587,6 +630,42 @@ test('capture prepare rejects rollback-candidate stopped data', (t) => {
587630
);
588631
});
589632

633+
test('capture prepare preserves exact adopted-target baseline evidence', (t) => {
634+
const state = fixture(t, {
635+
createDefaultSidecars: false,
636+
useAdoptedTargetBaseline: true,
637+
});
638+
assert.ok(state.adoptedTargetBaseline);
639+
assert.equal(state.reconciliation.baselineKind, 'adopted_target');
640+
assert.equal(
641+
state.reconciliation.baselineDigest,
642+
state.adoptedTargetBaseline.baselineDigest,
643+
);
644+
assert.equal(state.reconciliation.targetMatchesBaseline, false);
645+
const prepared = prepareLocalReconciliationCapture(state.command);
646+
assert.equal(prepared.status, 'prepared');
647+
assert.equal(prepared.state, 'reconciliation_capture_prepared');
648+
});
649+
650+
test('capture prepare rejects a valid but detached adopted-target baseline', (t) => {
651+
const state = fixture(t, {
652+
createDefaultSidecars: false,
653+
useAdoptedTargetBaseline: true,
654+
});
655+
const baselinePath = adoptedTargetBaselinePath(state.deploymentRoot);
656+
const detachedBaseline = createAdoptedTargetBaseline({
657+
...state.adoptedTargetBaseline,
658+
targetSha256: 'f'.repeat(64),
659+
});
660+
fs.writeFileSync(baselinePath, `${JSON.stringify(detachedBaseline)}\n`, {
661+
mode: 0o600,
662+
});
663+
assert.throws(
664+
() => prepareLocalReconciliationCapture(state.command),
665+
/adopted target baseline is detached from stopped evidence/,
666+
);
667+
});
668+
590669
test('service-manager stopped authority uses the same capture fence', (t) => {
591670
const state = fixture(t, { stoppedAuthority: 'service-manager' });
592671
const prepared = prepareLocalReconciliationCapture(state.command);

0 commit comments

Comments
 (0)