From 784e77a971d2338162b8e9934985b32a8a2f4d28 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 30 Aug 2026 21:11:47 +0800 Subject: [PATCH] fix(ql3): bind post-apply target baseline --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...0523-post-apply-adopted-target-baseline.md | 52 +++++ .../adopted-bundle/adoptedBundle.ts | 34 ++++ .../src/deployment/adopted-bundle/material.ts | 51 +++++ .../src/deployment/cutover/targetBaseline.ts | 180 ++++++++++++++++++ .../deployment/cutover/targetDataEvidence.ts | 146 +++++++++++--- .../src/deployment/cutover/targetEvidence.ts | 41 +++- .../test/adoptedDeploymentBundle.test.cjs | 28 +++ .../test/cutoverTargetRun.test.cjs | 142 ++++++++++++++ test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 10 files changed, 651 insertions(+), 29 deletions(-) create mode 100644 docs/adr/ADR-0523-post-apply-adopted-target-baseline.md create mode 100644 packages/ql3-local-owner-cli/src/deployment/cutover/targetBaseline.ts diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 7e8e173c..ca1b6ef4 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-426b2a/ADR-0523(源码候选,尚未形成新阶段实物):修复 D-426b 的真实架构矛盾:受认证 `local-data-directory.adoption.apply` 必然在 target 启动前改变 activation 中记录的 SQLite 内容摘要,因此旧 classifier 会把“apply 后未产生任何 target 写入”的合法停止错误判为 `reconciliation_required`。现仅为 `docker-target` adopted Application v4 发布 no-replace 私有 `service/adopted-target-baseline.json`,绑定 activation/legacy commitment、Application semantic digest、data application commit/receipt、target path/device/inode/SHA-256 和 sidecar-clear 事实;v4 target start/stop 必须闭合该基线,缺失或漂移进入 `manual_review`,启动后写入进入 `reconciliation_required`,未写入才得到 `rollback_candidate`。v3/fresh 与历史 journal shape/activation 语义保持不变;新停止证据同时保留真实 `targetMatchesActivation` 并增加 `baselineKind/baselineDigest/targetMatchesBaseline`。Local Owner CLI 完整包级门为 `308 total / 301 pass / 7 conditional skip / 0 fail`。没有新增 workspace package、依赖、daemon、listener、timer、watcher 或稳态资源。本切片仍不能冒充 D-426b2 阶段包:必须等 exact 上传 bundle 实跑 reviewed stage、transform/verify、Owner 强认证 apply/verify、真实 legacy stop、target start/stop 与 clean rollback,再生成同源双架构 artifact;当前可交付升级实物仍是 D-426a。 + - D-426b1/ADR-0522(源码候选,尚未形成新阶段实物):D-426a 暴露出真实的离线切换缺口:Trial Kit archive 只有本地 image reference 与 Docker content ID,既有 target barrier 却只接受 registry `name@sha256:...`;伪造 GHCR RepoDigest 或 Alpha release catalog 会污染正式发布权威。现统一引入 `{authority,reference,imageId}` target image identity,正式部署保持 `registry-digest`,离线 Trial Kit 使用 `local-image-id`,两者都同时核对容器 `Config.Image` 与 `.Image` content ID,并把三项绑定进 cutover journal。adopted bundle 新增 `docker-target` service kind,生成 Application v4 与内容绑定的 `service/docker-target.json`,固定 numeric UID:GID、restart=no、network none、read-only rootfs、drop ALL、no-new-privileges、Profile memory/PID 和 exact read-write/read-only mounts;descriptor 不创建或启动容器。没有新增 package、依赖或稳态进程。该切片只有定向源码测试证据,不能替代已交付的 D-426a 双架构 artifact;必须等 D-426b2 在 exact 上传 bundle 上实跑受认证 transform/apply、target start/stop 和 clean `rollback_candidate`,才允许宣称新阶段产物。 - D-426a/ADR-0521(已交付同源双架构 headless Alpha 阶段实物):二十天研发的阶段产物继续从“只读盘点”推进到仍不触碰生产切换的 side-by-side 暂存。Trial Kit 新增 canonical `upgrade-rehearsal.sh`,只接受操作者从 D-425 完整 evidence 中审核并显式提交的 SQLite/data-directory plan digest;它核对整包与 exact Operator identity 后,在新的 `0700/0600` rehearsal root 内以 read-only legacy bind、无网络、只读 rootfs、drop-all、128 MiB/0.5 CPU/32 PID 顺序执行 SQLite stage/verify/activation 和完整目录 stage/verify。`stage-summary.json` 绑定 source/architecture、两个 reviewed plan、SQLite manifest/activation 与目录 manifest digest,并固定 `legacySource=read_only`、`cutover=not_authorized`。Trial Kit/verification/auditor 升为 `@v7/@v5/@v4`,Local milestone 升为 `@v4` 并绑定双架构 `upgradeRehearsalSha256`;显式 artifact job 必须在将要上传的 exact bundle 上从 readiness plan 接续实跑 rehearsal。提交 `7a8acacb6cb49bda2116bf029fbbfe447ae5d911` 的普通 CI [run 33306005705](https://github.com/whyour/qinglong/actions/runs/33306005705) 为 41 success/3 expected scope skip/0 fail,同源 Kubernetes deployment [run 33306005706](https://github.com/whyour/qinglong/actions/runs/33306005706) 成功;显式 Local headless [run 33306650776](https://github.com/whyour/qinglong/actions/runs/33306650776) 为 42 success/2 Cluster scope skip/0 fail,exact 双架构 bundle 实跑 readiness 与 rehearsal 后生成 amd64 `187,554,547` bytes、arm64 `184,786,163` bytes 和 `6,206` bytes milestone v4,均保留至 2026-09-29。下载后的 milestone checksum 与离线 auditor 返回 `compatible=true`,并确认两个架构的 rehearsal digest 不同。没有 transform/apply、Owner/Secret authority、2.x stop、3.0 target start、cutover、rollback、migration、依赖、package、daemon、listener、timer 或稳态资源增量;D-426b/c 才分别闭合 adopted start/clean rollback 与 write-after reconciliation。 diff --git a/docs/adr/ADR-0523-post-apply-adopted-target-baseline.md b/docs/adr/ADR-0523-post-apply-adopted-target-baseline.md new file mode 100644 index 00000000..cb260b63 --- /dev/null +++ b/docs/adr/ADR-0523-post-apply-adopted-target-baseline.md @@ -0,0 +1,52 @@ +# ADR-0523:Apply 后的 Adopted Target 启动前基线 + +- 状态:Accepted(D-426b2a 源码候选;阶段实物尚未闭合) +- 日期:2026-08-30 +- 决策:D-426b2a + +## 上下文 + +D-426b1 已生成 Application v4 与内容绑定的离线 `docker-target.json`,但既有 clean rollback 判定只把 SQLite activation 中的初始 target SHA-256 当成目标基线。受认证 `local-data-directory.adoption.apply` 必然在 target 启动前向同一 SQLite 写入 Project、加密 Secret、disabled model、audit 与 receipt;因此合法 apply 后的 target 与 activation 内容摘要必然不同。若继续使用旧判定,未产生任何 post-cutover 写入的 target 也会错误进入 `reconciliation_required`;若直接把 activation 基线全局替换,又会削弱 fresh、v3、service-manager 和历史回退语义。 + +同时,target controller 原先只接受 Application v3,不能消费 D-426b1 生成的 v4 配置。这个矛盾意味着仅靠绿色源码无法形成新的阶段升级产物。 + +## 决策 + +只有 `docker-target` adopted bundle 在受认证 apply 已提交、target 尚未启动时发布私有 `service/adopted-target-baseline.json`。基线固定绑定: + +- Profile、instance、cutover、准备时间; +- activation 与 legacy-stopped commitment digest; +- Application v4 semantic digest; +- legacy data application commit/receipt digest; +- target canonical path digest、device、inode、SHA-256; +- `targetSidecarsClear=true`; +- 上述 payload 的自摘要 `baselineDigest`。 + +publication 使用现有 no-replace 私有文件协议。存在 SQLite WAL/SHM/journal、目标身份变化、配置/commit/receipt 漂移、基线缺失或自摘要不一致都 fail closed。 + +target controller 同时接受原有 Application v3 与 adopted Application v4: + +- v3/fresh 继续以 activation target SHA-256 判定,记录形状和旧语义不变; +- v4 必须读取并闭合 post-apply baseline;不得在缺失时退回 activation; +- v4 停止证据新增 `baselineKind=adopted_target`、`baselineDigest` 与 `targetMatchesBaseline`,同时保留真实的 `targetMatchesActivation`; +- baseline 未变且 legacy source/sidecar 未变才是 `rollback_candidate`;target 启动后发生写入则为 `reconciliation_required`;基线无法证明则为 `manual_review`。 + +旧 reconciliation journal 继续按原 exact shape 验证。新 shape 只对 adopted baseline 开放,并要求 disposition 与 `targetMatchesBaseline`、sidecar、source facts 一致。没有新增 workspace package、生产依赖、daemon、listener、timer、watcher 或稳态资源。 + +## 阶段实物门 + +本 ADR 只闭合此前不合理的 rollback 语义,仍不是新的可下载 Trial Kit。D-426a 双架构 headless bundle 仍是当前可交付的升级阶段实物。只有同源原生 amd64/arm64 artifact job 在将要上传的 exact bundle 上完成: + +1. reviewed stage/verify; +2. versioned transform/verify; +3. Owner credential + `secret.manage` 认证的 apply/verify; +4. exact offline image target 创建; +5. 真实 legacy stop、target start/stop; +6. v4 baseline 绑定且最终为 clean `rollback_candidate`; +7. bundle 与 milestone 离线审计; + +才能把 D-426b2 升级为阶段实物。任何一步只在仓库测试 fixture 中通过都不能替代 exact 上传包演练。 + +## 后续 + +D-426b2b 实现并审计上述 exact bundle 用户旅程,随后显式生成新的双架构 artifact。D-426c 继续处理 target 产生业务写入后的 capture、review、reconciliation 与恢复。 diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts index b3ed40d9..7e6d7135 100644 --- a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts @@ -215,6 +215,17 @@ export function prepareLocalDeploymentAdoptedBundle( identity.uid, 'adopted bundle receipt', ); + if (material.targetBaseline !== null) { + preflightPublishedFile( + material.paths.targetBaseline, + material.targetBaseline, + 0o600, + identity.uid, + 'adopted target baseline', + ); + } else if (fs.existsSync(material.paths.targetBaseline)) { + configurationError('process service cannot inherit a target baseline'); + } if (material.composeSelection !== null) { preflightPublishedFile( material.paths.composeRevision, @@ -309,6 +320,16 @@ export function prepareLocalDeploymentAdoptedBundle( identity.uid, 'adopted bundle receipt', ); + const baselineStatus = + material.targetBaseline === null + ? 'existing' + : publishExactFile( + material.paths.targetBaseline, + material.targetBaseline, + 0o600, + identity.uid, + 'adopted target baseline', + ); const createdDirectories = directoryStatuses.filter( (status) => status === 'prepared', ).length; @@ -318,6 +339,7 @@ export function prepareLocalDeploymentAdoptedBundle( serviceStatus === 'prepared' || composeRevisionStatus === 'prepared' || composeSelectionStatus === 'prepared' || + baselineStatus === 'prepared' || receiptStatus === 'prepared'; return Object.freeze({ schemaVersion: 1 as const, @@ -431,6 +453,18 @@ export function verifyLocalDeploymentAdoptedBundle( identity.gid, 'adopted bundle receipt', ); + if (material.targetBaseline !== null) { + verifyPublishedFile( + material.paths.targetBaseline, + material.targetBaseline, + 0o600, + identity.uid, + identity.gid, + 'adopted target baseline', + ); + } else if (fs.existsSync(material.paths.targetBaseline)) { + configurationError('process service cannot inherit a target baseline'); + } if (material.composeSelection !== null) { verifyPublishedFile( material.paths.composeRevision, diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts index 7340190e..01f4a074 100644 --- a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts @@ -8,6 +8,10 @@ import { } from '@qinglong/local-sqlite/data-directory-application-commit'; import { initialComposeImageSelectionFromAuthority } from '../compose/composeRevision'; +import { + adoptedTargetBaselinePath, + createAdoptedTargetBaseline, +} from '../cutover/targetBaseline'; import { cutoverDigest } from '../cutover/targetEvidence'; import { LocalDeploymentConfigurationError } from '../foundation/error'; import { composeProjectName } from '../foundation/render'; @@ -31,6 +35,7 @@ export interface LocalDeploymentAdoptedBundlePaths { readonly applicationConfig: string; readonly descriptor: string; readonly bundleReceipt: string; + readonly targetBaseline: string; readonly composeSelection: string; readonly composeRevisions: string; readonly composeRevision: string; @@ -52,6 +57,7 @@ export interface LocalDeploymentAdoptedEvidence { readonly manifestDigest: string; readonly sourceSha256: string; readonly recoverySha256: string; + readonly targetSha256: string; readonly targetDevice: string; readonly targetInode: string; readonly targetIdentityDigest: string; @@ -92,6 +98,7 @@ export interface LocalDeploymentAdoptedBundleMaterial { mode: number; }>; readonly composeSelection: string | null; + readonly targetBaseline: string | null; readonly receipt: Readonly; readonly receiptContents: string; } @@ -323,6 +330,7 @@ export function adoptedBundlePaths( applicationConfig: path.join(root, 'local-application.json'), descriptor: path.join(service, descriptorName), bundleReceipt: path.join(service, 'adopted-bundle.json'), + targetBaseline: adoptedTargetBaselinePath(root), composeSelection: path.join(service, 'compose.image.yaml'), composeRevisions: path.join(service, 'revisions'), composeRevision: path.join(service, 'revisions', '1.yaml'), @@ -538,6 +546,23 @@ export function verifyLocalDeploymentAdoptedEvidence( gid, 'recovery database', ); + const targetSidecars = ['-wal', '-shm', '-journal'].map((suffix) => + fs.existsSync(`${command.request.storage.targetPath}${suffix}`), + ); + const targetSha256 = stableFileSha256( + command.request.storage.targetPath, + uid, + gid, + 'target database', + ); + if ( + targetSidecars.some(Boolean) || + ['-wal', '-shm', '-journal'].some((suffix) => + fs.existsSync(`${command.request.storage.targetPath}${suffix}`), + ) + ) { + configurationError('target database sidecars must be absent'); + } if ( activation.targetDevice !== target.device || activation.targetInode !== target.inode || @@ -554,6 +579,7 @@ export function verifyLocalDeploymentAdoptedEvidence( manifestDigest, sourceSha256, recoverySha256, + targetSha256, targetDevice: target.device, targetInode: target.inode, targetIdentityDigest: cutoverDigest({ @@ -872,6 +898,30 @@ export function renderLocalDeploymentAdoptedBundleMaterial( changedAtMs: command.request.preparedAtMs, }) : null; + const targetBaseline = + command.options.service.kind === 'docker-target' + ? `${JSON.stringify( + createAdoptedTargetBaseline({ + preparedAtMs: command.request.preparedAtMs, + profile: command.options.profile, + instanceId: command.options.instanceId, + cutoverId: command.request.cutoverId, + activationDigest: evidence.activationDigest, + commitmentDigest: evidence.commitmentDigest, + applicationConfigDigest: cutoverDigest( + JSON.parse(config) as unknown, + ), + legacyDataApplicationCommitDigest: evidence.commitDigest, + legacyDataApplicationReceiptDigest: evidence.receiptDigest, + targetPathDigest: sha256(command.request.storage.targetPath), + targetDevice: evidence.targetDevice, + targetInode: evidence.targetInode, + targetSha256: evidence.targetSha256, + }), + null, + 2, + )}\n` + : null; const receiptPayload = { schemaVersion: 1 as const, kind: 'qinglong3-local-adopted-deployment-bundle' as const, @@ -906,6 +956,7 @@ export function renderLocalDeploymentAdoptedBundleMaterial( applicationConfig: config, descriptor, composeSelection, + targetBaseline, receipt, receiptContents: `${JSON.stringify(receipt, null, 2)}\n`, }); diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/targetBaseline.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/targetBaseline.ts new file mode 100644 index 00000000..50797020 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/targetBaseline.ts @@ -0,0 +1,180 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; + +import { LocalDeploymentConfigurationError } from '../foundation/contract'; +import { cutoverDigest } from './targetEvidence'; + +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +export interface AdoptedTargetBaseline { + readonly schemaVersion: 1; + readonly kind: 'qinglong3-local-adopted-target-baseline'; + readonly state: 'prepared'; + readonly preparedAtMs: number; + readonly profile: 'edge' | 'standalone'; + readonly instanceId: string; + readonly cutoverId: string; + readonly activationDigest: string; + readonly commitmentDigest: string; + readonly applicationConfigDigest: string; + readonly legacyDataApplicationCommitDigest: string; + readonly legacyDataApplicationReceiptDigest: string; + readonly targetPathDigest: string; + readonly targetDevice: string; + readonly targetInode: string; + readonly targetSha256: string; + readonly targetSidecarsClear: true; + readonly baselineDigest: string; +} + +export interface AdoptedTargetBaselineInput { + readonly preparedAtMs: number; + readonly profile: 'edge' | 'standalone'; + readonly instanceId: string; + readonly cutoverId: string; + readonly activationDigest: string; + readonly commitmentDigest: string; + readonly applicationConfigDigest: string; + readonly legacyDataApplicationCommitDigest: string; + readonly legacyDataApplicationReceiptDigest: string; + readonly targetPathDigest: string; + readonly targetDevice: string; + readonly targetInode: string; + readonly targetSha256: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError(message); +} + +function object(value: unknown): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError('adopted target baseline must be an object'); + } + return value as Record; +} + +function exact(value: Record, keys: readonly string[]): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError('adopted target baseline shape is invalid'); + } +} + +export function adoptedTargetBaselinePath(deploymentRoot: string): string { + return path.join(deploymentRoot, 'service', 'adopted-target-baseline.json'); +} + +export function createAdoptedTargetBaseline( + input: Readonly, +): Readonly { + const payload = { + schemaVersion: 1 as const, + kind: 'qinglong3-local-adopted-target-baseline' as const, + state: 'prepared' as const, + preparedAtMs: input.preparedAtMs, + profile: input.profile, + instanceId: input.instanceId, + cutoverId: input.cutoverId, + activationDigest: input.activationDigest, + commitmentDigest: input.commitmentDigest, + applicationConfigDigest: input.applicationConfigDigest, + legacyDataApplicationCommitDigest: input.legacyDataApplicationCommitDigest, + legacyDataApplicationReceiptDigest: + input.legacyDataApplicationReceiptDigest, + targetPathDigest: input.targetPathDigest, + targetDevice: input.targetDevice, + targetInode: input.targetInode, + targetSha256: input.targetSha256, + targetSidecarsClear: true as const, + }; + return Object.freeze({ + ...payload, + baselineDigest: cutoverDigest(payload), + }); +} + +export function readAdoptedTargetBaseline( + filePath: string, +): Readonly { + let baseline: Record; + try { + const stat = fs.lstatSync(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + fs.realpathSync(filePath) !== filePath + ) { + configurationError('adopted target baseline identity is invalid'); + } + baseline = object(readPrivateLocalCommandFile(filePath)); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + throw new LocalDeploymentConfigurationError( + 'adopted target baseline cannot be read', + { cause: error }, + ); + } + exact(baseline, [ + 'activationDigest', + 'applicationConfigDigest', + 'baselineDigest', + 'commitmentDigest', + 'cutoverId', + 'instanceId', + 'kind', + 'legacyDataApplicationCommitDigest', + 'legacyDataApplicationReceiptDigest', + 'preparedAtMs', + 'profile', + 'schemaVersion', + 'state', + 'targetDevice', + 'targetInode', + 'targetPathDigest', + 'targetSha256', + 'targetSidecarsClear', + ]); + const { baselineDigest, ...payload } = baseline; + if ( + baseline.schemaVersion !== 1 || + baseline.kind !== 'qinglong3-local-adopted-target-baseline' || + baseline.state !== 'prepared' || + !Number.isSafeInteger(baseline.preparedAtMs) || + (baseline.preparedAtMs as number) < 0 || + (baseline.profile !== 'edge' && baseline.profile !== 'standalone') || + typeof baseline.instanceId !== 'string' || + typeof baseline.cutoverId !== 'string' || + typeof baseline.targetDevice !== 'string' || + typeof baseline.targetInode !== 'string' || + baseline.targetSidecarsClear !== true || + [ + baseline.activationDigest, + baseline.commitmentDigest, + baseline.applicationConfigDigest, + baseline.legacyDataApplicationCommitDigest, + baseline.legacyDataApplicationReceiptDigest, + baseline.targetPathDigest, + baseline.targetSha256, + baselineDigest, + ].some( + (value) => typeof value !== 'string' || !DIGEST_PATTERN.test(value), + ) || + cutoverDigest(payload) !== baselineDigest + ) { + configurationError('adopted target baseline identity drifted'); + } + return baseline as unknown as Readonly; +} diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/targetDataEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/targetDataEvidence.ts index 4b007fae..91796724 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/targetDataEvidence.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/targetDataEvidence.ts @@ -5,7 +5,11 @@ import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; import type { LocalDeploymentTargetReconciliationDisposition } from './targetStopContract'; import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract'; -import { cutoverDigest } from './targetEvidence'; +import { + adoptedTargetBaselinePath, + readAdoptedTargetBaseline, +} from './targetBaseline'; +import { cutoverDigest, readTargetApplicationBinding } from './targetEvidence'; const DIGEST_PATTERN = /^[0-9a-f]{64}$/; const UNKNOWN_DIGEST = '0'.repeat(64); @@ -19,6 +23,9 @@ export interface TargetDataReconciliationEvidence { readonly sourceSidecarsClear: boolean | null; readonly targetFileIdentityDigest: string; readonly sourceFileIdentityDigest: string; + readonly baselineKind?: 'adopted_target'; + readonly baselineDigest?: string; + readonly targetMatchesBaseline?: boolean | null; readonly evidenceDigest: string; } @@ -28,6 +35,12 @@ export interface TargetDataReconciliationInput { readonly legacySourcePath: string; readonly targetDatabasePath: string; readonly expectedActivationDigest: string; + readonly adoptedTargetBaseline?: Readonly<{ + baselineDigest: string; + targetDevice: string; + targetInode: string; + targetSha256: string; + }>; } interface FileEvidence { @@ -155,20 +168,73 @@ function evidence( return Object.freeze({ ...payload, evidenceDigest: cutoverDigest(payload) }); } +function manualReviewEvidence(): Readonly { + return evidence({ + disposition: 'manual_review', + targetMatchesActivation: null, + sourceMatchesActivation: null, + targetSidecarsClear: null, + sourceSidecarsClear: null, + targetFileIdentityDigest: UNKNOWN_DIGEST, + sourceFileIdentityDigest: UNKNOWN_DIGEST, + }); +} + export function readTargetDataReconciliationEvidence( command: Readonly, uid: number, ): Readonly { - return readTargetDataReconciliationEvidenceForPaths( - { - profile: command.request.profile, - activationPath: command.request.activationPath, - legacySourcePath: command.request.legacySourcePath, - targetDatabasePath: command.request.targetDatabasePath, - expectedActivationDigest: command.request.expectedActivationDigest, - }, - uid, - ); + try { + const application = readTargetApplicationBinding(command); + let adoptedTargetBaseline: + | TargetDataReconciliationInput['adoptedTargetBaseline'] + | undefined; + if (application.schema === 'qinglong/local-application-process@v4') { + const baseline = readAdoptedTargetBaseline( + adoptedTargetBaselinePath(command.options.deploymentRoot), + ); + if ( + baseline.preparedAtMs > command.request.requestedAtMs || + baseline.profile !== command.request.profile || + baseline.instanceId !== command.request.instanceId || + baseline.cutoverId !== command.request.cutoverId || + baseline.activationDigest !== + command.request.expectedActivationDigest || + baseline.commitmentDigest !== + command.request.expectedLegacyCommitmentDigest || + baseline.applicationConfigDigest !== application.configDigest || + baseline.legacyDataApplicationCommitDigest !== + application.legacyDataApplicationCommitDigest || + baseline.legacyDataApplicationReceiptDigest !== + application.legacyDataApplicationReceiptDigest || + baseline.targetPathDigest !== + textDigest(command.request.targetDatabasePath) + ) { + throw new Error('adopted target baseline binding drifted'); + } + adoptedTargetBaseline = Object.freeze({ + baselineDigest: baseline.baselineDigest, + targetDevice: baseline.targetDevice, + targetInode: baseline.targetInode, + targetSha256: baseline.targetSha256, + }); + } + return readTargetDataReconciliationEvidenceForPaths( + { + profile: command.request.profile, + activationPath: command.request.activationPath, + legacySourcePath: command.request.legacySourcePath, + targetDatabasePath: command.request.targetDatabasePath, + expectedActivationDigest: command.request.expectedActivationDigest, + ...(adoptedTargetBaseline === undefined + ? {} + : { adoptedTargetBaseline }), + }, + uid, + ); + } catch { + return manualReviewEvidence(); + } } export function readTargetDataReconciliationEvidenceForPaths( @@ -215,19 +281,26 @@ export function readTargetDataReconciliationEvidenceForPaths( if ( target.pathDigest !== activation.targetPathDigest || target.device !== activation.targetDevice || - target.inode !== activation.targetInode + target.inode !== activation.targetInode || + (input.adoptedTargetBaseline !== undefined && + (target.device !== input.adoptedTargetBaseline.targetDevice || + target.inode !== input.adoptedTargetBaseline.targetInode)) ) { throw new Error('target database stable identity drifted'); } const targetMatchesActivation = target.sha256 === activation.targetSha256; + const targetMatchesBaseline = + input.adoptedTargetBaseline === undefined + ? targetMatchesActivation + : target.sha256 === input.adoptedTargetBaseline.targetSha256; const sourceMatchesActivation = source.sha256 === activation.sourceSha256; const disposition = - !targetMatchesActivation || !target.sidecarsClear + !targetMatchesBaseline || !target.sidecarsClear ? ('reconciliation_required' as const) : sourceMatchesActivation && source.sidecarsClear ? ('rollback_candidate' as const) : ('manual_review' as const); - return evidence({ + const reconciliationPayload = { disposition, targetMatchesActivation, sourceMatchesActivation, @@ -235,17 +308,19 @@ export function readTargetDataReconciliationEvidenceForPaths( sourceSidecarsClear: source.sidecarsClear, targetFileIdentityDigest: target.identityDigest, sourceFileIdentityDigest: source.identityDigest, - }); + }; + return evidence( + input.adoptedTargetBaseline === undefined + ? reconciliationPayload + : { + ...reconciliationPayload, + baselineKind: 'adopted_target' as const, + baselineDigest: input.adoptedTargetBaseline.baselineDigest, + targetMatchesBaseline, + }, + ); } catch { - return evidence({ - disposition: 'manual_review', - targetMatchesActivation: null, - sourceMatchesActivation: null, - targetSidecarsClear: null, - sourceSidecarsClear: null, - targetFileIdentityDigest: UNKNOWN_DIGEST, - sourceFileIdentityDigest: UNKNOWN_DIGEST, - }); + return manualReviewEvidence(); } } @@ -264,9 +339,16 @@ export function verifyTargetDataReconciliationEvidence( 'targetMatchesActivation', 'targetSidecarsClear', ].sort(); + const expectedAdopted = [ + ...expected, + 'baselineDigest', + 'baselineKind', + 'targetMatchesBaseline', + ].sort(); + const adopted = JSON.stringify(keys) === JSON.stringify(expectedAdopted); const { evidenceDigest, ...payload } = candidate; if ( - JSON.stringify(keys) !== JSON.stringify(expected) || + (!adopted && JSON.stringify(keys) !== JSON.stringify(expected)) || (candidate.disposition !== 'rollback_candidate' && candidate.disposition !== 'reconciliation_required' && candidate.disposition !== 'manual_review') || @@ -278,6 +360,20 @@ export function verifyTargetDataReconciliationEvidence( typeof candidate.targetSidecarsClear !== 'boolean') || (candidate.sourceSidecarsClear !== null && typeof candidate.sourceSidecarsClear !== 'boolean') || + (adopted && + (candidate.baselineKind !== 'adopted_target' || + typeof candidate.baselineDigest !== 'string' || + !DIGEST_PATTERN.test(candidate.baselineDigest) || + (candidate.targetMatchesBaseline !== null && + typeof candidate.targetMatchesBaseline !== 'boolean') || + (candidate.disposition === 'rollback_candidate' && + (candidate.targetMatchesBaseline !== true || + candidate.targetSidecarsClear !== true || + candidate.sourceMatchesActivation !== true || + candidate.sourceSidecarsClear !== true)) || + (candidate.disposition === 'reconciliation_required' && + candidate.targetMatchesBaseline !== false && + candidate.targetSidecarsClear !== false))) || typeof candidate.targetFileIdentityDigest !== 'string' || !DIGEST_PATTERN.test(candidate.targetFileIdentityDigest) || typeof candidate.sourceFileIdentityDigest !== 'string' || diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts index dcbff9f1..1211b658 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts @@ -20,12 +20,17 @@ export interface LegacySilenceEvidence { } export interface TargetApplicationBinding { + readonly schema: + | 'qinglong/local-application-process@v3' + | 'qinglong/local-application-process@v4'; readonly configDigest: string; readonly targetActivationPath: string; readonly targetLegacySourcePath: string; readonly targetDatabasePath: string; readonly targetRecoveryPath: string; readonly targetManifestPath: string; + readonly legacyDataApplicationCommitDigest: string | null; + readonly legacyDataApplicationReceiptDigest: string | null; } export interface TargetContainerEvidence { @@ -253,8 +258,22 @@ export function readTargetApplicationBinding( ); const storage = object(config.storage, 'target storage configuration'); const cutover = object(config.cutover, 'target cutover configuration'); + const isV4 = config.schema === 'qinglong/local-application-process@v4'; + const legacyDataApplication = isV4 + ? object( + config.legacyDataApplication, + 'target legacy data application configuration', + ) + : undefined; + if (legacyDataApplication !== undefined) { + exact( + legacyDataApplication, + ['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'], + 'target legacy data application configuration', + ); + } if ( - config.schema !== 'qinglong/local-application-process@v3' || + (config.schema !== 'qinglong/local-application-process@v3' && !isV4) || config.profile !== command.request.profile || config.instanceId !== command.request.instanceId || storage.mode !== 'adopted' || @@ -278,17 +297,35 @@ export function readTargetApplicationBinding( cutover.cutoverId !== command.request.cutoverId || cutover.commitmentPath !== command.request.expectedTargetCommitmentPath || cutover.expectedCommitmentDigest !== - command.request.expectedLegacyCommitmentDigest + command.request.expectedLegacyCommitmentDigest || + (legacyDataApplication !== undefined && + (typeof legacyDataApplication.commitPath !== 'string' || + !path.isAbsolute(legacyDataApplication.commitPath) || + path.normalize(legacyDataApplication.commitPath) !== + legacyDataApplication.commitPath || + typeof legacyDataApplication.expectedCommitDigest !== 'string' || + !DIGEST_PATTERN.test(legacyDataApplication.expectedCommitDigest) || + typeof legacyDataApplication.expectedReceiptDigest !== 'string' || + !DIGEST_PATTERN.test(legacyDataApplication.expectedReceiptDigest))) ) { configurationError('target application configuration binding is invalid'); } return Object.freeze({ + schema: config.schema as TargetApplicationBinding['schema'], configDigest: cutoverDigest(config), targetActivationPath: storage.activationPath, targetLegacySourcePath: storage.sourcePath, targetDatabasePath: storage.targetPath, targetRecoveryPath: storage.recoveryPath, targetManifestPath: storage.manifestPath, + legacyDataApplicationCommitDigest: + legacyDataApplication === undefined + ? null + : (legacyDataApplication.expectedCommitDigest as string), + legacyDataApplicationReceiptDigest: + legacyDataApplication === undefined + ? null + : (legacyDataApplication.expectedReceiptDigest as string), }); } diff --git a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs index 9f0ce1ea..484756a1 100644 --- a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs +++ b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs @@ -762,11 +762,39 @@ for (const kind of ['systemd', 'openrc', 'compose', 'docker-target']) { readOnly: true, }, ]); + const baseline = JSON.parse( + fs.readFileSync( + path.join(state.root, 'service/adopted-target-baseline.json'), + 'utf8', + ), + ); + const { baselineDigest, ...baselinePayload } = baseline; + assert.equal(baseline.kind, 'qinglong3-local-adopted-target-baseline'); + assert.equal( + baseline.applicationConfigDigest, + canonicalDigest(application), + ); + assert.equal( + baseline.legacyDataApplicationCommitDigest, + state.command.request.legacyDataApplication.expectedCommitDigest, + ); + assert.equal( + baseline.targetSha256, + hexDigest(fs.readFileSync(state.targetPath)), + ); + assert.equal(baseline.targetSidecarsClear, true); + assert.equal(baselineDigest, canonicalDigest(baselinePayload)); assert.equal( fs.existsSync(path.join(state.root, 'service/compose.image.yaml')), false, ); } else { + assert.equal( + fs.existsSync( + path.join(state.root, 'service/adopted-target-baseline.json'), + ), + false, + ); assert.equal( fs.existsSync(path.join(state.root, 'service/compose.image.yaml')), false, diff --git a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs index d42929f2..8ed3034c 100644 --- a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs +++ b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs @@ -26,6 +26,10 @@ function digest(value) { .digest('hex'); } +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + function targetPath(state, hostPath) { return path.join('/host', path.relative(state.managementRoot, hostPath)); } @@ -266,6 +270,72 @@ function fixture(t) { return state; } +function prepareAdoptedV4Baseline(state) { + const commitDigest = 'b'.repeat(64); + const receiptDigest = 'c'.repeat(64); + const config = { + schema: 'qinglong/local-application-process@v4', + profile: 'edge', + instanceId: 'edge-router-1', + storage: { + mode: 'adopted', + sourcePath: targetPath(state, state.legacySourcePath), + targetPath: targetPath(state, state.targetDatabasePath), + recoveryPath: targetPath(state, state.recoveryPath), + manifestPath: targetPath(state, state.manifestPath), + activationPath: targetPath(state, state.activationPath), + expectedActivationDigest: state.activationDigest, + }, + cutover: { + cutoverId: state.cutoverId, + commitmentPath: state.targetCommitmentPath, + expectedCommitmentDigest: state.legacyCommitmentDigest, + }, + legacyDataApplication: { + commitPath: path.join( + state.deploymentRoot, + 'transformation', + 'commit.json', + ), + expectedCommitDigest: commitDigest, + expectedReceiptDigest: receiptDigest, + }, + }; + fs.writeFileSync(state.applicationConfigPath, `${JSON.stringify(config)}\n`, { + mode: 0o600, + }); + const target = fs.statSync(state.targetDatabasePath, { bigint: true }); + const baselinePayload = { + schemaVersion: 1, + kind: 'qinglong3-local-adopted-target-baseline', + state: 'prepared', + preparedAtMs: 2_000, + profile: 'edge', + instanceId: 'edge-router-1', + cutoverId: state.cutoverId, + activationDigest: state.activationDigest, + commitmentDigest: state.legacyCommitmentDigest, + applicationConfigDigest: digest(config), + legacyDataApplicationCommitDigest: commitDigest, + legacyDataApplicationReceiptDigest: receiptDigest, + targetPathDigest: sha256(state.targetDatabasePath), + targetDevice: target.dev.toString(), + targetInode: target.ino.toString(), + targetSha256: sha256(fs.readFileSync(state.targetDatabasePath)), + targetSidecarsClear: true, + }; + const baseline = { + ...baselinePayload, + baselineDigest: digest(baselinePayload), + }; + fs.writeFileSync( + path.join(state.deploymentRoot, 'service/adopted-target-baseline.json'), + `${JSON.stringify(baseline)}\n`, + { mode: 0o600 }, + ); + return baseline; +} + function command(state, generation = 1) { return { schemaVersion: 1, @@ -989,6 +1059,78 @@ test('stops an active target and proves an unchanged rollback candidate', async ); }); +test('uses the authenticated v4 post-apply baseline for clean rollback', async (t) => { + const state = fixture(t); + fs.writeFileSync(state.targetDatabasePath, 'authenticated applied target\n'); + const baseline = prepareAdoptedV4Baseline(state); + await runLocalDeploymentDockerTarget(command(state), harness(state)); + const stopped = stopLocalDeploymentDockerTarget( + stopCommand(state), + harness(state), + ); + assert.equal(stopped.state, 'target_stopped'); + assert.equal(stopped.reconciliation, 'rollback_candidate'); + const outcome = JSON.parse( + fs.readFileSync( + path.join(state.journal, '0006-target-stop-outcome.json'), + 'utf8', + ), + ); + const reconciliation = outcome.evidence.reconciliation; + assert.equal(reconciliation.baselineKind, 'adopted_target'); + assert.equal(reconciliation.baselineDigest, baseline.baselineDigest); + assert.equal(reconciliation.targetMatchesActivation, false); + assert.equal(reconciliation.targetMatchesBaseline, true); + assert.equal(reconciliation.sourceMatchesActivation, true); +}); + +test('requires reconciliation when the v4 target writes after its baseline', async (t) => { + const state = fixture(t); + fs.writeFileSync(state.targetDatabasePath, 'authenticated applied target\n'); + const baseline = prepareAdoptedV4Baseline(state); + await runLocalDeploymentDockerTarget(command(state), harness(state)); + fs.writeFileSync(state.targetDatabasePath, 'post-cutover write\n'); + const stopped = stopLocalDeploymentDockerTarget( + stopCommand(state), + harness(state), + ); + assert.equal(stopped.reconciliation, 'reconciliation_required'); + const outcome = JSON.parse( + fs.readFileSync( + path.join(state.journal, '0006-target-stop-outcome.json'), + 'utf8', + ), + ); + assert.equal( + outcome.evidence.reconciliation.baselineDigest, + baseline.baselineDigest, + ); + assert.equal(outcome.evidence.reconciliation.targetMatchesBaseline, false); +}); + +test('fails a drifted v4 target baseline closed to manual review', async (t) => { + const state = fixture(t); + fs.writeFileSync(state.targetDatabasePath, 'authenticated applied target\n'); + prepareAdoptedV4Baseline(state); + await runLocalDeploymentDockerTarget(command(state), harness(state)); + const baselinePath = path.join( + state.deploymentRoot, + 'service/adopted-target-baseline.json', + ); + const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + fs.writeFileSync( + baselinePath, + `${JSON.stringify({ ...baseline, targetSha256: 'd'.repeat(64) })}\n`, + { mode: 0o600 }, + ); + const stopped = stopLocalDeploymentDockerTarget( + stopCommand(state), + harness(state), + ); + assert.equal(stopped.state, 'target_stopped'); + assert.equal(stopped.reconciliation, 'manual_review'); +}); + test('prepares and commits an exact legacy rollback without mutating target data', async (t) => { const state = fixture(t); await runLocalDeploymentDockerTarget(command(state), harness(state)); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 64578373..f41a7ff6 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 189, + sourceFiles: 190, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 188, + nestedSourceFiles: 189, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );