mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
fix(ql3): bind post-apply target baseline
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# QingLong 3.0 Architecture RFC
|
# 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-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。
|
- 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。
|
||||||
|
|||||||
@@ -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 与恢复。
|
||||||
@@ -215,6 +215,17 @@ export function prepareLocalDeploymentAdoptedBundle(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
'adopted bundle receipt',
|
'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) {
|
if (material.composeSelection !== null) {
|
||||||
preflightPublishedFile(
|
preflightPublishedFile(
|
||||||
material.paths.composeRevision,
|
material.paths.composeRevision,
|
||||||
@@ -309,6 +320,16 @@ export function prepareLocalDeploymentAdoptedBundle(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
'adopted bundle receipt',
|
'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(
|
const createdDirectories = directoryStatuses.filter(
|
||||||
(status) => status === 'prepared',
|
(status) => status === 'prepared',
|
||||||
).length;
|
).length;
|
||||||
@@ -318,6 +339,7 @@ export function prepareLocalDeploymentAdoptedBundle(
|
|||||||
serviceStatus === 'prepared' ||
|
serviceStatus === 'prepared' ||
|
||||||
composeRevisionStatus === 'prepared' ||
|
composeRevisionStatus === 'prepared' ||
|
||||||
composeSelectionStatus === 'prepared' ||
|
composeSelectionStatus === 'prepared' ||
|
||||||
|
baselineStatus === 'prepared' ||
|
||||||
receiptStatus === 'prepared';
|
receiptStatus === 'prepared';
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1 as const,
|
schemaVersion: 1 as const,
|
||||||
@@ -431,6 +453,18 @@ export function verifyLocalDeploymentAdoptedBundle(
|
|||||||
identity.gid,
|
identity.gid,
|
||||||
'adopted bundle receipt',
|
'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) {
|
if (material.composeSelection !== null) {
|
||||||
verifyPublishedFile(
|
verifyPublishedFile(
|
||||||
material.paths.composeRevision,
|
material.paths.composeRevision,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from '@qinglong/local-sqlite/data-directory-application-commit';
|
} from '@qinglong/local-sqlite/data-directory-application-commit';
|
||||||
|
|
||||||
import { initialComposeImageSelectionFromAuthority } from '../compose/composeRevision';
|
import { initialComposeImageSelectionFromAuthority } from '../compose/composeRevision';
|
||||||
|
import {
|
||||||
|
adoptedTargetBaselinePath,
|
||||||
|
createAdoptedTargetBaseline,
|
||||||
|
} from '../cutover/targetBaseline';
|
||||||
import { cutoverDigest } from '../cutover/targetEvidence';
|
import { cutoverDigest } from '../cutover/targetEvidence';
|
||||||
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
import { composeProjectName } from '../foundation/render';
|
import { composeProjectName } from '../foundation/render';
|
||||||
@@ -31,6 +35,7 @@ export interface LocalDeploymentAdoptedBundlePaths {
|
|||||||
readonly applicationConfig: string;
|
readonly applicationConfig: string;
|
||||||
readonly descriptor: string;
|
readonly descriptor: string;
|
||||||
readonly bundleReceipt: string;
|
readonly bundleReceipt: string;
|
||||||
|
readonly targetBaseline: string;
|
||||||
readonly composeSelection: string;
|
readonly composeSelection: string;
|
||||||
readonly composeRevisions: string;
|
readonly composeRevisions: string;
|
||||||
readonly composeRevision: string;
|
readonly composeRevision: string;
|
||||||
@@ -52,6 +57,7 @@ export interface LocalDeploymentAdoptedEvidence {
|
|||||||
readonly manifestDigest: string;
|
readonly manifestDigest: string;
|
||||||
readonly sourceSha256: string;
|
readonly sourceSha256: string;
|
||||||
readonly recoverySha256: string;
|
readonly recoverySha256: string;
|
||||||
|
readonly targetSha256: string;
|
||||||
readonly targetDevice: string;
|
readonly targetDevice: string;
|
||||||
readonly targetInode: string;
|
readonly targetInode: string;
|
||||||
readonly targetIdentityDigest: string;
|
readonly targetIdentityDigest: string;
|
||||||
@@ -92,6 +98,7 @@ export interface LocalDeploymentAdoptedBundleMaterial {
|
|||||||
mode: number;
|
mode: number;
|
||||||
}>;
|
}>;
|
||||||
readonly composeSelection: string | null;
|
readonly composeSelection: string | null;
|
||||||
|
readonly targetBaseline: string | null;
|
||||||
readonly receipt: Readonly<LocalDeploymentAdoptedBundleReceipt>;
|
readonly receipt: Readonly<LocalDeploymentAdoptedBundleReceipt>;
|
||||||
readonly receiptContents: string;
|
readonly receiptContents: string;
|
||||||
}
|
}
|
||||||
@@ -323,6 +330,7 @@ export function adoptedBundlePaths(
|
|||||||
applicationConfig: path.join(root, 'local-application.json'),
|
applicationConfig: path.join(root, 'local-application.json'),
|
||||||
descriptor: path.join(service, descriptorName),
|
descriptor: path.join(service, descriptorName),
|
||||||
bundleReceipt: path.join(service, 'adopted-bundle.json'),
|
bundleReceipt: path.join(service, 'adopted-bundle.json'),
|
||||||
|
targetBaseline: adoptedTargetBaselinePath(root),
|
||||||
composeSelection: path.join(service, 'compose.image.yaml'),
|
composeSelection: path.join(service, 'compose.image.yaml'),
|
||||||
composeRevisions: path.join(service, 'revisions'),
|
composeRevisions: path.join(service, 'revisions'),
|
||||||
composeRevision: path.join(service, 'revisions', '1.yaml'),
|
composeRevision: path.join(service, 'revisions', '1.yaml'),
|
||||||
@@ -538,6 +546,23 @@ export function verifyLocalDeploymentAdoptedEvidence(
|
|||||||
gid,
|
gid,
|
||||||
'recovery database',
|
'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 (
|
if (
|
||||||
activation.targetDevice !== target.device ||
|
activation.targetDevice !== target.device ||
|
||||||
activation.targetInode !== target.inode ||
|
activation.targetInode !== target.inode ||
|
||||||
@@ -554,6 +579,7 @@ export function verifyLocalDeploymentAdoptedEvidence(
|
|||||||
manifestDigest,
|
manifestDigest,
|
||||||
sourceSha256,
|
sourceSha256,
|
||||||
recoverySha256,
|
recoverySha256,
|
||||||
|
targetSha256,
|
||||||
targetDevice: target.device,
|
targetDevice: target.device,
|
||||||
targetInode: target.inode,
|
targetInode: target.inode,
|
||||||
targetIdentityDigest: cutoverDigest({
|
targetIdentityDigest: cutoverDigest({
|
||||||
@@ -872,6 +898,30 @@ export function renderLocalDeploymentAdoptedBundleMaterial(
|
|||||||
changedAtMs: command.request.preparedAtMs,
|
changedAtMs: command.request.preparedAtMs,
|
||||||
})
|
})
|
||||||
: null;
|
: 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 = {
|
const receiptPayload = {
|
||||||
schemaVersion: 1 as const,
|
schemaVersion: 1 as const,
|
||||||
kind: 'qinglong3-local-adopted-deployment-bundle' as const,
|
kind: 'qinglong3-local-adopted-deployment-bundle' as const,
|
||||||
@@ -906,6 +956,7 @@ export function renderLocalDeploymentAdoptedBundleMaterial(
|
|||||||
applicationConfig: config,
|
applicationConfig: config,
|
||||||
descriptor,
|
descriptor,
|
||||||
composeSelection,
|
composeSelection,
|
||||||
|
targetBaseline,
|
||||||
receipt,
|
receipt,
|
||||||
receiptContents: `${JSON.stringify(receipt, null, 2)}\n`,
|
receiptContents: `${JSON.stringify(receipt, null, 2)}\n`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(value: Record<string, unknown>, 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<AdoptedTargetBaselineInput>,
|
||||||
|
): Readonly<AdoptedTargetBaseline> {
|
||||||
|
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<AdoptedTargetBaseline> {
|
||||||
|
let baseline: Record<string, unknown>;
|
||||||
|
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<AdoptedTargetBaseline>;
|
||||||
|
}
|
||||||
@@ -5,7 +5,11 @@ import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
|||||||
|
|
||||||
import type { LocalDeploymentTargetReconciliationDisposition } from './targetStopContract';
|
import type { LocalDeploymentTargetReconciliationDisposition } from './targetStopContract';
|
||||||
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
|
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 DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
const UNKNOWN_DIGEST = '0'.repeat(64);
|
const UNKNOWN_DIGEST = '0'.repeat(64);
|
||||||
@@ -19,6 +23,9 @@ export interface TargetDataReconciliationEvidence {
|
|||||||
readonly sourceSidecarsClear: boolean | null;
|
readonly sourceSidecarsClear: boolean | null;
|
||||||
readonly targetFileIdentityDigest: string;
|
readonly targetFileIdentityDigest: string;
|
||||||
readonly sourceFileIdentityDigest: string;
|
readonly sourceFileIdentityDigest: string;
|
||||||
|
readonly baselineKind?: 'adopted_target';
|
||||||
|
readonly baselineDigest?: string;
|
||||||
|
readonly targetMatchesBaseline?: boolean | null;
|
||||||
readonly evidenceDigest: string;
|
readonly evidenceDigest: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +35,12 @@ export interface TargetDataReconciliationInput {
|
|||||||
readonly legacySourcePath: string;
|
readonly legacySourcePath: string;
|
||||||
readonly targetDatabasePath: string;
|
readonly targetDatabasePath: string;
|
||||||
readonly expectedActivationDigest: string;
|
readonly expectedActivationDigest: string;
|
||||||
|
readonly adoptedTargetBaseline?: Readonly<{
|
||||||
|
baselineDigest: string;
|
||||||
|
targetDevice: string;
|
||||||
|
targetInode: string;
|
||||||
|
targetSha256: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileEvidence {
|
interface FileEvidence {
|
||||||
@@ -155,10 +168,57 @@ function evidence(
|
|||||||
return Object.freeze({ ...payload, evidenceDigest: cutoverDigest(payload) });
|
return Object.freeze({ ...payload, evidenceDigest: cutoverDigest(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function manualReviewEvidence(): Readonly<TargetDataReconciliationEvidence> {
|
||||||
|
return evidence({
|
||||||
|
disposition: 'manual_review',
|
||||||
|
targetMatchesActivation: null,
|
||||||
|
sourceMatchesActivation: null,
|
||||||
|
targetSidecarsClear: null,
|
||||||
|
sourceSidecarsClear: null,
|
||||||
|
targetFileIdentityDigest: UNKNOWN_DIGEST,
|
||||||
|
sourceFileIdentityDigest: UNKNOWN_DIGEST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function readTargetDataReconciliationEvidence(
|
export function readTargetDataReconciliationEvidence(
|
||||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||||
uid: number,
|
uid: number,
|
||||||
): Readonly<TargetDataReconciliationEvidence> {
|
): Readonly<TargetDataReconciliationEvidence> {
|
||||||
|
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(
|
return readTargetDataReconciliationEvidenceForPaths(
|
||||||
{
|
{
|
||||||
profile: command.request.profile,
|
profile: command.request.profile,
|
||||||
@@ -166,9 +226,15 @@ export function readTargetDataReconciliationEvidence(
|
|||||||
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,
|
||||||
);
|
);
|
||||||
|
} catch {
|
||||||
|
return manualReviewEvidence();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readTargetDataReconciliationEvidenceForPaths(
|
export function readTargetDataReconciliationEvidenceForPaths(
|
||||||
@@ -215,19 +281,26 @@ export function readTargetDataReconciliationEvidenceForPaths(
|
|||||||
if (
|
if (
|
||||||
target.pathDigest !== activation.targetPathDigest ||
|
target.pathDigest !== activation.targetPathDigest ||
|
||||||
target.device !== activation.targetDevice ||
|
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');
|
throw new Error('target database stable identity drifted');
|
||||||
}
|
}
|
||||||
const targetMatchesActivation = target.sha256 === activation.targetSha256;
|
const targetMatchesActivation = target.sha256 === activation.targetSha256;
|
||||||
|
const targetMatchesBaseline =
|
||||||
|
input.adoptedTargetBaseline === undefined
|
||||||
|
? targetMatchesActivation
|
||||||
|
: target.sha256 === input.adoptedTargetBaseline.targetSha256;
|
||||||
const sourceMatchesActivation = source.sha256 === activation.sourceSha256;
|
const sourceMatchesActivation = source.sha256 === activation.sourceSha256;
|
||||||
const disposition =
|
const disposition =
|
||||||
!targetMatchesActivation || !target.sidecarsClear
|
!targetMatchesBaseline || !target.sidecarsClear
|
||||||
? ('reconciliation_required' as const)
|
? ('reconciliation_required' as const)
|
||||||
: sourceMatchesActivation && source.sidecarsClear
|
: sourceMatchesActivation && source.sidecarsClear
|
||||||
? ('rollback_candidate' as const)
|
? ('rollback_candidate' as const)
|
||||||
: ('manual_review' as const);
|
: ('manual_review' as const);
|
||||||
return evidence({
|
const reconciliationPayload = {
|
||||||
disposition,
|
disposition,
|
||||||
targetMatchesActivation,
|
targetMatchesActivation,
|
||||||
sourceMatchesActivation,
|
sourceMatchesActivation,
|
||||||
@@ -235,17 +308,19 @@ export function readTargetDataReconciliationEvidenceForPaths(
|
|||||||
sourceSidecarsClear: source.sidecarsClear,
|
sourceSidecarsClear: source.sidecarsClear,
|
||||||
targetFileIdentityDigest: target.identityDigest,
|
targetFileIdentityDigest: target.identityDigest,
|
||||||
sourceFileIdentityDigest: source.identityDigest,
|
sourceFileIdentityDigest: source.identityDigest,
|
||||||
});
|
};
|
||||||
|
return evidence(
|
||||||
|
input.adoptedTargetBaseline === undefined
|
||||||
|
? reconciliationPayload
|
||||||
|
: {
|
||||||
|
...reconciliationPayload,
|
||||||
|
baselineKind: 'adopted_target' as const,
|
||||||
|
baselineDigest: input.adoptedTargetBaseline.baselineDigest,
|
||||||
|
targetMatchesBaseline,
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return evidence({
|
return manualReviewEvidence();
|
||||||
disposition: 'manual_review',
|
|
||||||
targetMatchesActivation: null,
|
|
||||||
sourceMatchesActivation: null,
|
|
||||||
targetSidecarsClear: null,
|
|
||||||
sourceSidecarsClear: null,
|
|
||||||
targetFileIdentityDigest: UNKNOWN_DIGEST,
|
|
||||||
sourceFileIdentityDigest: UNKNOWN_DIGEST,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,9 +339,16 @@ export function verifyTargetDataReconciliationEvidence(
|
|||||||
'targetMatchesActivation',
|
'targetMatchesActivation',
|
||||||
'targetSidecarsClear',
|
'targetSidecarsClear',
|
||||||
].sort();
|
].sort();
|
||||||
|
const expectedAdopted = [
|
||||||
|
...expected,
|
||||||
|
'baselineDigest',
|
||||||
|
'baselineKind',
|
||||||
|
'targetMatchesBaseline',
|
||||||
|
].sort();
|
||||||
|
const adopted = JSON.stringify(keys) === JSON.stringify(expectedAdopted);
|
||||||
const { evidenceDigest, ...payload } = candidate;
|
const { evidenceDigest, ...payload } = candidate;
|
||||||
if (
|
if (
|
||||||
JSON.stringify(keys) !== JSON.stringify(expected) ||
|
(!adopted && JSON.stringify(keys) !== JSON.stringify(expected)) ||
|
||||||
(candidate.disposition !== 'rollback_candidate' &&
|
(candidate.disposition !== 'rollback_candidate' &&
|
||||||
candidate.disposition !== 'reconciliation_required' &&
|
candidate.disposition !== 'reconciliation_required' &&
|
||||||
candidate.disposition !== 'manual_review') ||
|
candidate.disposition !== 'manual_review') ||
|
||||||
@@ -278,6 +360,20 @@ export function verifyTargetDataReconciliationEvidence(
|
|||||||
typeof candidate.targetSidecarsClear !== 'boolean') ||
|
typeof candidate.targetSidecarsClear !== 'boolean') ||
|
||||||
(candidate.sourceSidecarsClear !== null &&
|
(candidate.sourceSidecarsClear !== null &&
|
||||||
typeof candidate.sourceSidecarsClear !== 'boolean') ||
|
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' ||
|
typeof candidate.targetFileIdentityDigest !== 'string' ||
|
||||||
!DIGEST_PATTERN.test(candidate.targetFileIdentityDigest) ||
|
!DIGEST_PATTERN.test(candidate.targetFileIdentityDigest) ||
|
||||||
typeof candidate.sourceFileIdentityDigest !== 'string' ||
|
typeof candidate.sourceFileIdentityDigest !== 'string' ||
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ export interface LegacySilenceEvidence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetApplicationBinding {
|
export interface TargetApplicationBinding {
|
||||||
|
readonly schema:
|
||||||
|
| 'qinglong/local-application-process@v3'
|
||||||
|
| 'qinglong/local-application-process@v4';
|
||||||
readonly configDigest: string;
|
readonly configDigest: string;
|
||||||
readonly targetActivationPath: string;
|
readonly targetActivationPath: string;
|
||||||
readonly targetLegacySourcePath: string;
|
readonly targetLegacySourcePath: string;
|
||||||
readonly targetDatabasePath: string;
|
readonly targetDatabasePath: string;
|
||||||
readonly targetRecoveryPath: string;
|
readonly targetRecoveryPath: string;
|
||||||
readonly targetManifestPath: string;
|
readonly targetManifestPath: string;
|
||||||
|
readonly legacyDataApplicationCommitDigest: string | null;
|
||||||
|
readonly legacyDataApplicationReceiptDigest: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetContainerEvidence {
|
export interface TargetContainerEvidence {
|
||||||
@@ -253,8 +258,22 @@ export function readTargetApplicationBinding(
|
|||||||
);
|
);
|
||||||
const storage = object(config.storage, 'target storage configuration');
|
const storage = object(config.storage, 'target storage configuration');
|
||||||
const cutover = object(config.cutover, 'target cutover 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 (
|
if (
|
||||||
config.schema !== 'qinglong/local-application-process@v3' ||
|
(config.schema !== 'qinglong/local-application-process@v3' && !isV4) ||
|
||||||
config.profile !== command.request.profile ||
|
config.profile !== command.request.profile ||
|
||||||
config.instanceId !== command.request.instanceId ||
|
config.instanceId !== command.request.instanceId ||
|
||||||
storage.mode !== 'adopted' ||
|
storage.mode !== 'adopted' ||
|
||||||
@@ -278,17 +297,35 @@ export function readTargetApplicationBinding(
|
|||||||
cutover.cutoverId !== command.request.cutoverId ||
|
cutover.cutoverId !== command.request.cutoverId ||
|
||||||
cutover.commitmentPath !== command.request.expectedTargetCommitmentPath ||
|
cutover.commitmentPath !== command.request.expectedTargetCommitmentPath ||
|
||||||
cutover.expectedCommitmentDigest !==
|
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');
|
configurationError('target application configuration binding is invalid');
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
schema: config.schema as TargetApplicationBinding['schema'],
|
||||||
configDigest: cutoverDigest(config),
|
configDigest: cutoverDigest(config),
|
||||||
targetActivationPath: storage.activationPath,
|
targetActivationPath: storage.activationPath,
|
||||||
targetLegacySourcePath: storage.sourcePath,
|
targetLegacySourcePath: storage.sourcePath,
|
||||||
targetDatabasePath: storage.targetPath,
|
targetDatabasePath: storage.targetPath,
|
||||||
targetRecoveryPath: storage.recoveryPath,
|
targetRecoveryPath: storage.recoveryPath,
|
||||||
targetManifestPath: storage.manifestPath,
|
targetManifestPath: storage.manifestPath,
|
||||||
|
legacyDataApplicationCommitDigest:
|
||||||
|
legacyDataApplication === undefined
|
||||||
|
? null
|
||||||
|
: (legacyDataApplication.expectedCommitDigest as string),
|
||||||
|
legacyDataApplicationReceiptDigest:
|
||||||
|
legacyDataApplication === undefined
|
||||||
|
? null
|
||||||
|
: (legacyDataApplication.expectedReceiptDigest as string),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -762,11 +762,39 @@ for (const kind of ['systemd', 'openrc', 'compose', 'docker-target']) {
|
|||||||
readOnly: true,
|
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(
|
assert.equal(
|
||||||
fs.existsSync(path.join(state.root, 'service/compose.image.yaml')),
|
fs.existsSync(path.join(state.root, 'service/compose.image.yaml')),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(state.root, 'service/adopted-target-baseline.json'),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.existsSync(path.join(state.root, 'service/compose.image.yaml')),
|
fs.existsSync(path.join(state.root, 'service/compose.image.yaml')),
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ function digest(value) {
|
|||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
function targetPath(state, hostPath) {
|
function targetPath(state, hostPath) {
|
||||||
return path.join('/host', path.relative(state.managementRoot, hostPath));
|
return path.join('/host', path.relative(state.managementRoot, hostPath));
|
||||||
}
|
}
|
||||||
@@ -266,6 +270,72 @@ function fixture(t) {
|
|||||||
return state;
|
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) {
|
function command(state, generation = 1) {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
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) => {
|
test('prepares and commits an exact legacy rollback without mutating target data', async (t) => {
|
||||||
const state = fixture(t);
|
const state = fixture(t);
|
||||||
await runLocalDeploymentDockerTarget(command(state), harness(state));
|
await runLocalDeploymentDockerTarget(command(state), harness(state));
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 189,
|
sourceFiles: 190,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 188,
|
nestedSourceFiles: 189,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user