From ad291e514df41235695082990472e7602a7fa3e8 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 20 Aug 2026 16:34:30 +0800 Subject: [PATCH] feat(ql3): prepare service manager legacy rollback --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 19 + ...ice-manager-legacy-rollback-preparation.md | 98 +++ docs/adr/README.md | 1 + .../deployment/cutover/targetDataEvidence.ts | 40 +- .../src/deployment/localDeployment.ts | 13 + .../src/deployment/localDeploymentCli.ts | 6 +- .../service-manager/serviceLegacyRollback.ts | 633 ++++++++++++++++++ .../test/serviceCutoverConsumer.test.cjs | 180 +++++ ...ql3-service-manager-bridge-docker-gate.cjs | 12 + .../ql3-service-manager-bridge-live-actor.cjs | 65 +- test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 11 files changed, 1058 insertions(+), 13 deletions(-) create mode 100644 docs/adr/ADR-0472-service-manager-legacy-rollback-preparation.md create mode 100644 packages/ql3-local-owner-cli/src/deployment/service-manager/serviceLegacyRollback.ts diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 6d8e7ae6..f59c0e64 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,25 @@ 最新增量证据(2026-08-20): +- D-379/ADR-0472(已接受;OpenRC live actor 待镜像基础设施恢复后补跑):为 systemd/OpenRC adopted cutover 增加 Owner-side + `service-legacy-rollback-prepare`。命令绑定 exact `target_stopped` record 与 instance head,重读 stop intent、Application v3、 + activation 和 legacy-silence commitment,并复用 Docker 路径相同的 stable-fd SHA-256/inode/sidecar reconciliation。 + 只有 target 等于 activation、source 等于 recovery 且双方 SQLite sidecar clear 的 `rollback_candidate` 才 no-replace 发布 + digest-bound preparation 并 CAS 到 `rollback_prepared`;target 写后或证据不确定返回结构化 `not-prepared`,保持 + `target_stopped`,不错误进入 terminal `manual_required`。本切片位于现有 Local Owner 包,不新增 workspace package、 + production dependency、数据库连接、daemon、watcher、timer 或 root mutation;root-only systemd/OpenRC legacy start、 + inspect-only crash convergence、最终 `legacy_running` 消费与 2.x health proof 留给下一阶段。定向 prepare 门 `10/10`,Local + Owner 全量 `175 total / 170 pass / 5 conditional skip / 0 fail`,完整 backend 为 + `1,507 total / 1,505 pass / 2 conditional skip / 0 fail`,18-package clean build/逐包测试单次退出 0。package boundary、 + Service Bridge import、Edge import、Cluster dependency、Cluster/Worker deployment、Console 与 Console distribution 八项审计 + 全部 compatible/passed;workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Local Owner 为 + `108 source / 107 nested / 1 root binary entry`。14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 保持 + `2,598,669 / 2,598,747` bytes、316 files、57 loaded modules,Adopted 为 `2,817,964 / 2,818,087` bytes、58 loaded modules, + Application+AI 为 `4,501,822 / 4,501,954` bytes,MCP 为 `7,324,601 / 7,324,709` bytes。Docker live gate 已完成 systemd + root/non-root 两个 actor 的真实 stop、prepare 与 exact replay;OpenRC 两个 actor 因 `node:24-alpine` registry mirror EOF 与 + credential helper 挂起未执行,不能宣称四组合门已闭合,镜像基础设施恢复后须补跑。本切片不改变 PostgreSQL schema、ACL、 + repository、role、Pool、连接或 failover 语义,因此不重跑且不重新占有 HA 证明;D-373/D-374 PostgreSQL 18.6 arm64 HA + `146/146`、timeline `1→2` 仅作为相邻既有基线。 - D-378/ADR-0471(已接受):把孵化重点从连续 Cluster Console 增量转回 3.0 首发兼容闭环,新增直接运行生产 Cron/Subscription Express Router 与 Celebrate validator 的 2.x 核心执行 API 基线。Cron 覆盖 list/create/update/disable/enable/run/stop、单日志、日志列表和 单实例 stop;Subscription 覆盖 list/create/update/disable/enable/run/stop/log。确定性 TypeDI spy 同时锁定服务参数与现行 JSON envelope,尤其 diff --git a/docs/adr/ADR-0472-service-manager-legacy-rollback-preparation.md b/docs/adr/ADR-0472-service-manager-legacy-rollback-preparation.md new file mode 100644 index 00000000..c57f6639 --- /dev/null +++ b/docs/adr/ADR-0472-service-manager-legacy-rollback-preparation.md @@ -0,0 +1,98 @@ +# ADR-0472:Service Manager Legacy Rollback Preparation + +- 状态:Accepted +- 日期:2026-08-20 +- 关联 RFC:QL-RFC-0001 D-64、D-274、D-275、D-379 +- 关联 ADR:ADR-0314、ADR-0315、ADR-0362、ADR-0363 + +## 背景 + +QingLong 3.0 的 Docker adopted cutover 已有 `target_stopped → rollback_prepared → legacy_running` 双阶段回退, +但 systemd/OpenRC 双 authority 路径只做到 `target_stopped`。该记录证明 3.0 service 已停止、shutdown receipt 与 +进程身份闭合,却没有重新计算 target/source SQLite 的哈希、稳定 inode 与 `-wal|-shm|-journal` sidecar,也没有一份 +可由 root bridge 消费的持久回退授权。 + +直接复用 Docker rollback coordinator 不成立:Docker controller 与数据文件同属 Owner authority,而 systemd/OpenRC +需要把 Owner 私有状态判定与 root-only init mutation 分开。与此同时,低配路由器不能为回退增加数据库连接、常驻 +supervisor、watcher 或第二套 SQLite 语义。 + +编辑前 GitNexus 对共享 reconciliation reader、service cutover consumer、cutover record parser 与 CLI main 的影响均为 +LOW:最多 2 个直接调用者、6 个累计上游符号、0 条已识别 execution flow。 + +## 决策 + +### 1. 先关闭 Owner-side prepare,不在本阶段执行 root mutation + +在现有 `@qinglong/local-owner-cli` 包内增加: + +```text +ql3-local-deploy service-legacy-rollback-prepare --command-file +``` + +命令必须提交 exact `cutoverId/profile/instanceId/generation/activationDigest`、`target_stopped` record digest 与 +instance head digest。Owner 重新读取并绑定: + +- 当前实例 head 必须仍是相同 generation 的 `target_stopped`; +- stop record 必须来自 action=`stop`,含有效 shutdown receipt 且没有 manual reason; +- 原 stop intent、Application v3 config、activation 与 legacy-silence commitment 的 digest/路径/lineage 必须一致; +- target/source SQLite 必须在同一稳定 fd 快照内完成 SHA-256,inode/path/size/mtime 与 sidecar 状态必须闭合。 + +只有 `targetMatchesActivation=true`、`sourceMatchesRecovery=true` 且双方 sidecar clear 时,disposition 才是 +`rollback_candidate`,命令才 no-replace 发布 +`service-manager-gNN-rollback-prepared.json` 并把 instance head CAS 到 `rollback_prepared`。prepare 不执行 +start/stop/restart/enable,不修改 SQLite,也不读取 Docker socket。 + +### 2. 非候选不污染实例终态 + +target 已产生写入或存在 target sidecar 时返回 `not-prepared/reconciliation_required`;source 与 recovery 不一致或证据 +无法稳定读取时返回 `not-prepared/manual_review`。两者都保持原 `target_stopped` head,不写 preparation,不进入 +`manual_required`。这是为了保留显式重启 3.0 或进入后续数据 reconciliation ceremony 的能力;回退不可用不等于实例 +已不可恢复。 + +### 3. Docker 与 service-manager 共用同一 reconciliation 实现 + +将既有 reader 抽成按 `profile/activation/source/target/expectedActivationDigest` 采集的内部共享接口,Docker 原入口继续 +委托它,保持现有调用与返回 contract。这样两类部署使用相同哈希、stable-fd、sidecar 和 fail-closed 规则,不复制一套 +容易漂移的数据判定。 + +### 4. 保持 package 粒度 + +实现继续位于 `local-owner-cli/src/deployment/service-manager`,没有新增 workspace package、production dependency、 +binary 制品或常驻组件。模块边界按 Owner prepare 责任划分;只有将来 root bridge 需要独立 OS 分发/签名/依赖闭包时, +才重新评估是否拆包。 + +## 被否决方案 + +1. **stop 成功后直接启动 legacy**:没有数据重验证和 durable authorization,拒绝。 +2. **非 rollback candidate 直接写 manual_required**:会错误封死 3.0 正向恢复,拒绝。 +3. **复制 Docker reconciliation 代码**:两套哈希/sidecar 语义会漂移,拒绝。 +4. **让 root bridge 自己读取 Owner SQLite 并决定能否回退**:混淆数据 authority 与 init mutation authority,拒绝。 +5. **为 prepare 新建 workspace package**:没有独立交付和依赖边界,不制造微包。 + +## 验收证据 + +- 定向 service cutover/rollback preparation `10/10`,覆盖成功、exact replay、target 写后拒绝、Application config 漂移与 + legacy-silence commitment 漂移拒绝。 +- Local Owner 完整测试为 `175 total / 170 pass / 5 conditional skip / 0 fail`;18-package clean build/逐包测试单次退出 0; + backend 全量为 `1,507 total / 1,505 pass / 2 conditional skip / 0 fail`。 +- package boundary、Service Bridge import、Edge import、Cluster dependency、Cluster/Worker deployment、Console 与 Console + distribution 八项审计全部 compatible/passed。workspace 保持 18 packages、`singleSourcePackages=[]`、 + `shallowSourcePackages=[]`;Local Owner 为 `108 source / 107 nested / 1 root binary entry`,没有形成单文件或浅层微包。 +- 14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,598,669 / 2,598,747` bytes、316 files、 + 57 loaded modules,Adopted 为 `2,817,964 / 2,818,087` bytes、58 loaded modules,Application+AI 为 + `4,501,822 / 4,501,954` bytes,MCP 为 `7,324,601 / 7,324,709` bytes。prepare 没有进入基础路由器闭包。 +- Docker live gate 已依次完成 systemd root/non-root 两个 actor 的真实 stop、prepare 与 exact replay,随后在构建 OpenRC actor + 前因 `node:24-alpine` registry mirror EOF/本机 credential helper 挂起而停止;因此本 ADR 不把 systemd/OpenRC 四组合门 + 记为完整通过,待镜像基础设施恢复后补跑 OpenRC root/non-root。测试容器和临时镜像均已清理。 +- 本阶段不修改 SQL、migration、PostgreSQL repository/role/Pool 或 HA 语义,因此不重新生成 PostgreSQL HA 证据。 + +## 未完成 + +- root-only systemd/OpenRC legacy start barrier、响应丢失后的 inspect-only 收敛; +- Owner 对 legacy running、target stopped 与 preparation/data 未漂移的最终消费; +- `legacy_running` 后的 2.x readiness/health proof; +- `reconciliation_required` 的 export、冲突裁决与受审回灌; +- 固定物理 Edge 的完整 prepare/commit/rollback 证据。 + +本 ADR 只关闭 service-manager 回退的 Owner prepare 阶段,不宣称 systemd/OpenRC legacy rollback controller 或 +QingLong 3.0 升级/回退 Gate 已全部完成。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 8929def1..357dfda1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -475,6 +475,7 @@ | [ADR-0469](./ADR-0469-optional-console-package-installation-observation.md) | 可选 Console Package Installation 只读观察 | Accepted | | [ADR-0470](./ADR-0470-session-scoped-console-capability-discovery.md) | Console 会话级能力发现与服务端操作围栏 | Accepted | | [ADR-0471](./ADR-0471-legacy-core-api-compatibility-baseline.md) | Legacy 核心执行 API 兼容基线 | Accepted | +| [ADR-0472](./ADR-0472-service-manager-legacy-rollback-preparation.md) | Service Manager Legacy Rollback Preparation | Accepted | ## 规则 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 2a8f737b..bf71d9f6 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/targetDataEvidence.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/targetDataEvidence.ts @@ -22,6 +22,14 @@ export interface TargetDataReconciliationEvidence { readonly evidenceDigest: string; } +export interface TargetDataReconciliationInput { + readonly profile: 'edge' | 'standalone'; + readonly activationPath: string; + readonly legacySourcePath: string; + readonly targetDatabasePath: string; + readonly expectedActivationDigest: string; +} + interface FileEvidence { readonly sha256: string; readonly identityDigest: string; @@ -150,22 +158,36 @@ function evidence( 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, + ); +} + +export function readTargetDataReconciliationEvidenceForPaths( + input: Readonly, + uid: number, ): Readonly { try { const activation = object( - readPrivateLocalCommandFile(command.request.activationPath), + readPrivateLocalCommandFile(input.activationPath), ); const { activationDigest, ...payload } = activation; if ( activation.schemaVersion !== 1 || activation.kind !== 'qinglong3-local-sqlite-activation' || activation.state !== 'prepared' || - activation.profile !== command.request.profile || - activation.sourcePathDigest !== - textDigest(command.request.legacySourcePath) || - activation.targetPathDigest !== - textDigest(command.request.targetDatabasePath) || - activationDigest !== command.request.expectedActivationDigest || + activation.profile !== input.profile || + activation.sourcePathDigest !== textDigest(input.legacySourcePath) || + activation.targetPathDigest !== textDigest(input.targetDatabasePath) || + activationDigest !== input.expectedActivationDigest || typeof activationDigest !== 'string' || !DIGEST_PATTERN.test(activationDigest) || typeof activation.targetSha256 !== 'string' || @@ -179,12 +201,12 @@ export function readTargetDataReconciliationEvidence( throw new Error('activation identity drifted'); } const target = fileEvidence( - command.request.targetDatabasePath, + input.targetDatabasePath, uid, 'target database', ); const source = fileEvidence( - command.request.legacySourcePath, + input.legacySourcePath, uid, 'legacy source database', ); diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 9de1961c..8dfd2d04 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -79,6 +79,10 @@ import { consumeLocalServiceManagerCutoverOutcome, consumeLocalServiceManagerCutoverOutcomeCommandFile, } from './service-manager/serviceCutoverConsumer'; +import { + prepareLocalServiceManagerLegacyRollback, + prepareLocalServiceManagerLegacyRollbackCommandFile, +} from './service-manager/serviceLegacyRollback'; export { LocalDeploymentConfigurationError, @@ -181,6 +185,8 @@ export { consumeLocalServiceManagerCutoverOutcomeCommandFile, prepareLocalServiceManagerIntent, prepareLocalServiceManagerIntentCommandFile, + prepareLocalServiceManagerLegacyRollback, + prepareLocalServiceManagerLegacyRollbackCommandFile, }; export { localServiceManagerIntentDigest, @@ -209,6 +215,13 @@ export { type LocalServiceManagerCutoverRecord, type LocalServiceManagerCutoverState, } from './service-manager/serviceCutoverJournal'; +export { + localServiceManagerLegacyRollbackPreparationPath, + normalizeLocalServiceManagerLegacyRollbackPreparation, + type LocalServiceManagerLegacyRollbackPreparation, + type LocalServiceManagerLegacyRollbackPrepareCommand, + type LocalServiceManagerLegacyRollbackPrepareResult, +} from './service-manager/serviceLegacyRollback'; export async function prepareLocalDeployment( input: unknown, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index 6c631a8e..5af8ae55 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -10,6 +10,7 @@ import { inspectLocalDeploymentStatusCommandFile, preflightLocalDeploymentComposeCommandFile, prepareLocalServiceManagerIntentCommandFile, + prepareLocalServiceManagerLegacyRollbackCommandFile, prepareLocalDeploymentCommandFile, restoreLocalDeploymentComposeCommitCommandFile, restoreLocalDeploymentComposePrepareCommandFile, @@ -22,7 +23,7 @@ import { } from './localDeployment'; const USAGE = - 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -36,6 +37,7 @@ async function main(argv: readonly string[]): Promise { argv[0] !== 'service-intent-prepare' && argv[0] !== 'service-outcome-consume' && argv[0] !== 'service-cutover-consume' && + argv[0] !== 'service-legacy-rollback-prepare' && argv[0] !== 'cutover-legacy-stop' && argv[0] !== 'cutover-target-start' && argv[0] !== 'cutover-target-restart' && @@ -74,6 +76,8 @@ async function main(argv: readonly string[]): Promise { ? consumeLocalServiceManagerOutcomeCommandFile(argv[2]!) : argv[0] === 'service-cutover-consume' ? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!) + : argv[0] === 'service-legacy-rollback-prepare' + ? prepareLocalServiceManagerLegacyRollbackCommandFile(argv[2]!) : argv[0] === 'cutover-legacy-stop' ? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!) : argv[0] === 'cutover-target-start' || diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceLegacyRollback.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceLegacyRollback.ts new file mode 100644 index 00000000..4da7731d --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceLegacyRollback.ts @@ -0,0 +1,633 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + MAX_PRIVATE_LOCAL_JSON_FILE_BYTES, + readPrivateLocalCommandFile, + readPrivateLocalJsonFile, +} from '@qinglong/local-command-file'; + +import { + advanceLocalCutoverInstanceHead, + readLocalCutoverInstanceHead, +} from '../cutover/instanceLineage'; +import { + readTargetDataReconciliationEvidenceForPaths, + verifyTargetDataReconciliationEvidence, + type TargetDataReconciliationEvidence, +} from '../cutover/targetDataEvidence'; +import { cutoverDigest } from '../cutover/targetEvidence'; +import { currentIdentity } from '../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../foundation/error'; +import { preflightPublishedFile, publishExactFile } from '../foundation/files'; +import { + localServiceManagerIntentPath, + normalizeLocalServiceManagerIntent, +} from './serviceBridgeContract'; +import { normalizeLocalServiceManagerCutoverRecord } from './serviceCutoverJournal'; + +const SCHEMA = 'qinglong3-local-service-manager-rollback-preparation'; +const ZERO_DIGEST = '0'.repeat(64); +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; +const MAX_PATH_BYTES = 4_096; +const MAX_GENERATION = 15; + +export interface LocalServiceManagerLegacyRollbackPrepareCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.service-manager.legacy-rollback.prepare'; + readonly options: Readonly<{ + deploymentRoot: string; + allowRootService: boolean; + }>; + readonly request: Readonly<{ + cutoverId: string; + profile: 'edge' | 'standalone'; + instanceId: string; + generation: number; + expectedActivationDigest: string; + expectedStoppedRecordDigest: string; + expectedInstanceHeadDigest: string; + requestedAtMs: number; + }>; +} + +export interface LocalServiceManagerLegacyRollbackPrepareResult { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.service-manager.legacy-rollback.prepare'; + readonly status: 'prepared' | 'existing' | 'not-prepared'; + readonly state: 'rollback_prepared' | 'target_stopped'; + readonly rollbackDisposition: TargetDataReconciliationEvidence['disposition']; + readonly cutoverId: string; + readonly generation: number; + readonly preparationDigest: string; + readonly instanceHeadDigest: string; +} + +export interface LocalServiceManagerLegacyRollbackPreparation { + readonly schema: typeof SCHEMA; + readonly schemaVersion: 1; + readonly state: 'rollback_prepared'; + readonly cutoverId: string; + readonly profile: 'edge' | 'standalone'; + readonly instanceId: string; + readonly generation: number; + readonly activationDigest: string; + readonly expectedInstanceHeadDigest: string; + readonly stoppedRecordDigest: string; + readonly stoppedManagerOutcomeDigest: string; + readonly applicationConfigDigest: string; + readonly commitmentDigest: string; + readonly shutdownReceiptDigest: string; + readonly reconciliation: Readonly; + readonly requestedAtMs: number; + readonly preparationDigest: string; +} + +interface AdoptedPaths { + readonly activationPath: string; + readonly legacySourcePath: string; + readonly targetDatabasePath: string; + readonly expectedActivationDigest: string; + readonly commitmentDigest: string; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError(message, { cause }); +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: 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(`${label} shape is invalid`); + } +} + +function safeAbsolutePath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + path.normalize(value) !== value || + path.parse(value).root === value || + value.includes('\0') || + value.includes('//') || + !SAFE_PATH_PATTERN.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + configurationError(`${label} must be a supervisor-safe absolute path`); + } + return value; +} + +function normalizeCommand( + value: unknown, +): Readonly { + const command = object(value, 'service manager rollback prepare command'); + exact( + command, + ['operation', 'options', 'request', 'schemaVersion'], + 'command', + ); + const options = object(command.options, 'options'); + exact(options, ['allowRootService', 'deploymentRoot'], 'options'); + const request = object(command.request, 'request'); + exact( + request, + [ + 'cutoverId', + 'expectedActivationDigest', + 'expectedInstanceHeadDigest', + 'expectedStoppedRecordDigest', + 'generation', + 'instanceId', + 'profile', + 'requestedAtMs', + ], + 'request', + ); + const identity = currentIdentity(); + if ( + command.schemaVersion !== 1 || + command.operation !== + 'local.deployment.service-manager.legacy-rollback.prepare' || + typeof options.allowRootService !== 'boolean' || + (identity.uid === 0) !== options.allowRootService || + typeof request.cutoverId !== 'string' || + !CUTOVER_ID_PATTERN.test(request.cutoverId) || + (request.profile !== 'edge' && request.profile !== 'standalone') || + typeof request.instanceId !== 'string' || + !INSTANCE_ID_PATTERN.test(request.instanceId) || + !Number.isSafeInteger(request.generation) || + (request.generation as number) < 1 || + (request.generation as number) > MAX_GENERATION || + typeof request.expectedActivationDigest !== 'string' || + !DIGEST_PATTERN.test(request.expectedActivationDigest) || + typeof request.expectedStoppedRecordDigest !== 'string' || + !DIGEST_PATTERN.test(request.expectedStoppedRecordDigest) || + typeof request.expectedInstanceHeadDigest !== 'string' || + !DIGEST_PATTERN.test(request.expectedInstanceHeadDigest) || + !Number.isSafeInteger(request.requestedAtMs) || + (request.requestedAtMs as number) < 0 + ) { + configurationError('service manager rollback prepare command is invalid'); + } + return Object.freeze({ + schemaVersion: 1 as const, + operation: + 'local.deployment.service-manager.legacy-rollback.prepare' as const, + options: Object.freeze({ + deploymentRoot: safeAbsolutePath( + options.deploymentRoot, + 'deploymentRoot', + ), + allowRootService: options.allowRootService, + }), + request: Object.freeze({ + cutoverId: request.cutoverId, + profile: request.profile, + instanceId: request.instanceId, + generation: request.generation as number, + expectedActivationDigest: request.expectedActivationDigest, + expectedStoppedRecordDigest: request.expectedStoppedRecordDigest, + expectedInstanceHeadDigest: request.expectedInstanceHeadDigest, + requestedAtMs: request.requestedAtMs as number, + }), + }); +} + +function stoppedRecordPath( + command: Readonly, +): string { + return path.join( + command.options.deploymentRoot, + 'service', + 'cutovers', + command.request.cutoverId, + `service-manager-g${String(command.request.generation).padStart( + 2, + '0', + )}-stopped.json`, + ); +} + +export function localServiceManagerLegacyRollbackPreparationPath( + deploymentRoot: string, + cutoverId: string, + generation: number, +): string { + return path.join( + deploymentRoot, + 'service', + 'cutovers', + cutoverId, + `service-manager-g${String(generation).padStart( + 2, + '0', + )}-rollback-prepared.json`, + ); +} + +function applicationSha256(filePath: string): string { + try { + return crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex'); + } catch (error) { + return configurationError( + 'application configuration cannot be hashed', + error, + ); + } +} + +function adoptedPaths( + applicationPath: string, + command: Readonly, + expectedApplicationDigest: string, + expectedCommitmentDigest: string, +): Readonly { + if (applicationSha256(applicationPath) !== expectedApplicationDigest) { + configurationError('adopted application configuration digest drifted'); + } + const application = object( + readPrivateLocalCommandFile(applicationPath), + 'adopted application configuration', + ); + const storage = object(application.storage, 'adopted storage'); + const cutover = object(application.cutover, 'adopted cutover'); + const commitmentPath = safeAbsolutePath( + cutover.commitmentPath, + 'commitmentPath', + ); + const expectedCommitmentPath = path.join( + command.options.deploymentRoot, + 'service', + 'cutovers', + command.request.cutoverId, + '0002-legacy-stopped.json', + ); + const commitment = object( + readPrivateLocalCommandFile(commitmentPath), + 'legacy silence commitment', + ); + const { commitmentDigest, ...commitmentPayload } = commitment; + if ( + application.schema !== 'qinglong/local-application-process@v3' || + application.profile !== command.request.profile || + application.instanceId !== command.request.instanceId || + storage.mode !== 'adopted' || + storage.expectedActivationDigest !== + command.request.expectedActivationDigest || + cutover.cutoverId !== command.request.cutoverId || + cutover.expectedCommitmentDigest !== expectedCommitmentDigest || + commitmentPath !== expectedCommitmentPath || + commitment.schemaVersion !== 1 || + commitment.kind !== 'qinglong3-local-legacy-silence-commitment' || + commitment.state !== 'legacy_stopped' || + commitment.cutoverId !== command.request.cutoverId || + commitment.profile !== command.request.profile || + commitment.instanceId !== command.request.instanceId || + commitment.activationDigest !== command.request.expectedActivationDigest || + commitmentDigest !== expectedCommitmentDigest || + cutoverDigest(commitmentPayload) !== commitmentDigest + ) { + configurationError('adopted application rollback binding drifted'); + } + return Object.freeze({ + activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'), + legacySourcePath: safeAbsolutePath(storage.sourcePath, 'legacySourcePath'), + targetDatabasePath: safeAbsolutePath( + storage.targetPath, + 'targetDatabasePath', + ), + expectedActivationDigest: command.request.expectedActivationDigest, + commitmentDigest: expectedCommitmentDigest, + }); +} + +function preparationRecord( + command: Readonly, + stopped: ReturnType, + reconciliation: Readonly, +): Readonly { + if ( + stopped.evidence.shutdownReceiptDigest === null || + stopped.evidence.manualReason !== null + ) { + configurationError('stopped service evidence cannot authorize rollback'); + } + const payload = Object.freeze({ + schema: SCHEMA, + schemaVersion: 1 as const, + state: 'rollback_prepared' as const, + cutoverId: command.request.cutoverId, + profile: command.request.profile, + instanceId: command.request.instanceId, + generation: command.request.generation, + activationDigest: command.request.expectedActivationDigest, + expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest, + stoppedRecordDigest: stopped.recordDigest, + stoppedManagerOutcomeDigest: stopped.evidence.managerOutcomeDigest, + applicationConfigDigest: stopped.evidence.applicationConfigDigest, + commitmentDigest: stopped.evidence.commitmentDigest, + shutdownReceiptDigest: stopped.evidence.shutdownReceiptDigest, + reconciliation, + requestedAtMs: command.request.requestedAtMs, + }); + return Object.freeze({ + ...payload, + preparationDigest: cutoverDigest(payload), + }); +} + +export function normalizeLocalServiceManagerLegacyRollbackPreparation( + value: unknown, +): Readonly { + const record = object(value, 'service manager rollback preparation'); + exact( + record, + [ + 'activationDigest', + 'applicationConfigDigest', + 'commitmentDigest', + 'cutoverId', + 'expectedInstanceHeadDigest', + 'generation', + 'instanceId', + 'preparationDigest', + 'profile', + 'reconciliation', + 'requestedAtMs', + 'schema', + 'schemaVersion', + 'shutdownReceiptDigest', + 'state', + 'stoppedManagerOutcomeDigest', + 'stoppedRecordDigest', + ], + 'service manager rollback preparation', + ); + const reconciliation = verifyTargetDataReconciliationEvidence( + record.reconciliation, + ); + const { preparationDigest, ...rawPayload } = record; + const payload = { ...rawPayload, reconciliation }; + if ( + record.schema !== SCHEMA || + record.schemaVersion !== 1 || + record.state !== 'rollback_prepared' || + typeof record.cutoverId !== 'string' || + !CUTOVER_ID_PATTERN.test(record.cutoverId) || + (record.profile !== 'edge' && record.profile !== 'standalone') || + typeof record.instanceId !== 'string' || + !INSTANCE_ID_PATTERN.test(record.instanceId) || + !Number.isSafeInteger(record.generation) || + (record.generation as number) < 1 || + (record.generation as number) > MAX_GENERATION || + typeof record.activationDigest !== 'string' || + !DIGEST_PATTERN.test(record.activationDigest) || + typeof record.expectedInstanceHeadDigest !== 'string' || + !DIGEST_PATTERN.test(record.expectedInstanceHeadDigest) || + typeof record.stoppedRecordDigest !== 'string' || + !DIGEST_PATTERN.test(record.stoppedRecordDigest) || + typeof record.stoppedManagerOutcomeDigest !== 'string' || + !DIGEST_PATTERN.test(record.stoppedManagerOutcomeDigest) || + typeof record.applicationConfigDigest !== 'string' || + !DIGEST_PATTERN.test(record.applicationConfigDigest) || + typeof record.commitmentDigest !== 'string' || + !DIGEST_PATTERN.test(record.commitmentDigest) || + typeof record.shutdownReceiptDigest !== 'string' || + !DIGEST_PATTERN.test(record.shutdownReceiptDigest) || + reconciliation.disposition !== 'rollback_candidate' || + !Number.isSafeInteger(record.requestedAtMs) || + (record.requestedAtMs as number) < 0 || + typeof preparationDigest !== 'string' || + !DIGEST_PATTERN.test(preparationDigest) || + cutoverDigest(payload) !== preparationDigest + ) { + configurationError('service manager rollback preparation drifted'); + } + return Object.freeze({ + ...(payload as Omit< + LocalServiceManagerLegacyRollbackPreparation, + 'preparationDigest' + >), + preparationDigest, + }); +} + +function result( + command: Readonly, + status: LocalServiceManagerLegacyRollbackPrepareResult['status'], + state: LocalServiceManagerLegacyRollbackPrepareResult['state'], + reconciliation: Readonly, + preparationDigest: string, + instanceHeadDigest: string, +): Readonly { + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status, + state, + rollbackDisposition: reconciliation.disposition, + cutoverId: command.request.cutoverId, + generation: command.request.generation, + preparationDigest, + instanceHeadDigest, + }); +} + +export function prepareLocalServiceManagerLegacyRollback( + input: unknown, +): Readonly { + const command = normalizeCommand(input); + const identity = currentIdentity(); + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + command.request.instanceId, + identity.uid, + ); + if ( + head.profile !== command.request.profile || + head.cutoverId !== command.request.cutoverId || + head.activationDigest !== command.request.expectedActivationDigest || + head.generation !== command.request.generation || + (head.state !== 'target_stopped' && head.state !== 'rollback_prepared') + ) { + configurationError('rollback prepare is not bound to the instance head'); + } + const stopped = normalizeLocalServiceManagerCutoverRecord( + readPrivateLocalCommandFile(stoppedRecordPath(command)), + ); + if ( + stopped.state !== 'target_stopped' || + stopped.action !== 'stop' || + stopped.cutoverId !== command.request.cutoverId || + stopped.profile !== command.request.profile || + stopped.instanceId !== command.request.instanceId || + stopped.activationDigest !== command.request.expectedActivationDigest || + stopped.generation !== command.request.generation || + stopped.recordDigest !== command.request.expectedStoppedRecordDigest || + stopped.completedAtMs > command.request.requestedAtMs || + (head.state === 'target_stopped' && + head.sourceRecordDigest !== stopped.recordDigest) || + (head.state === 'target_stopped' && + head.headDigest !== command.request.expectedInstanceHeadDigest) + ) { + configurationError('stopped service rollback binding drifted'); + } + const intent = normalizeLocalServiceManagerIntent( + readPrivateLocalJsonFile( + localServiceManagerIntentPath( + command.options.deploymentRoot, + stopped.actionId, + ), + { maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES }, + ), + ); + if ( + !UUID_V4_PATTERN.test(intent.actionId) || + intent.intentDigest !== stopped.intentDigest || + intent.action !== 'stop' || + intent.profile !== command.request.profile || + intent.instanceId !== command.request.instanceId || + intent.lineage.mode !== 'adopted' || + intent.lineage.cutoverId !== command.request.cutoverId || + intent.lineage.generation !== command.request.generation || + intent.lineage.expectedActivationDigest !== + command.request.expectedActivationDigest + ) { + configurationError('stopped service intent binding drifted'); + } + const paths = adoptedPaths( + intent.deployment.applicationConfigPath, + command, + stopped.evidence.applicationConfigDigest, + stopped.evidence.commitmentDigest, + ); + const reconciliation = readTargetDataReconciliationEvidenceForPaths( + { + profile: command.request.profile, + activationPath: paths.activationPath, + legacySourcePath: paths.legacySourcePath, + targetDatabasePath: paths.targetDatabasePath, + expectedActivationDigest: paths.expectedActivationDigest, + }, + identity.uid, + ); + if (reconciliation.disposition !== 'rollback_candidate') { + if (head.state !== 'target_stopped') { + configurationError('prepared rollback data drifted after authorization'); + } + return result( + command, + 'not-prepared', + 'target_stopped', + reconciliation, + ZERO_DIGEST, + head.headDigest, + ); + } + const preparation = preparationRecord(command, stopped, reconciliation); + const filePath = localServiceManagerLegacyRollbackPreparationPath( + command.options.deploymentRoot, + command.request.cutoverId, + command.request.generation, + ); + if (head.state === 'rollback_prepared') { + const existing = normalizeLocalServiceManagerLegacyRollbackPreparation( + readPrivateLocalCommandFile(filePath), + ); + if ( + existing.preparationDigest !== preparation.preparationDigest || + head.sourceRecordDigest !== existing.preparationDigest || + head.previousHeadDigest !== command.request.expectedInstanceHeadDigest + ) { + configurationError('service manager rollback preparation replay drifted'); + } + return result( + command, + 'existing', + 'rollback_prepared', + reconciliation, + existing.preparationDigest, + head.headDigest, + ); + } + const contents = `${JSON.stringify(preparation, null, 2)}\n`; + preflightPublishedFile( + filePath, + contents, + 0o600, + identity.uid, + 'service manager rollback preparation', + ); + const status = publishExactFile( + filePath, + contents, + 0o600, + identity.uid, + 'service manager rollback preparation', + ); + const next = advanceLocalCutoverInstanceHead( + { + options: { deploymentRoot: command.options.deploymentRoot }, + request: { + cutoverId: command.request.cutoverId, + profile: command.request.profile, + instanceId: command.request.instanceId, + expectedActivationDigest: command.request.expectedActivationDigest, + requestedAtMs: command.request.requestedAtMs, + }, + }, + identity.uid, + 'rollback_prepared', + command.request.generation, + preparation.preparationDigest, + ); + return result( + command, + status, + 'rollback_prepared', + reconciliation, + preparation.preparationDigest, + next.headDigest, + ); +} + +export function prepareLocalServiceManagerLegacyRollbackCommandFile( + filePath: string, +): Readonly { + return prepareLocalServiceManagerLegacyRollback( + readPrivateLocalJsonFile(filePath, { + maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES, + }), + ); +} diff --git a/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs b/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs index 863c6a2b..8903faa5 100644 --- a/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs +++ b/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs @@ -8,6 +8,10 @@ const { test } = require('node:test'); const { consumeLocalServiceManagerCutoverOutcome, } = require('../dist/deployment/service-manager/serviceCutoverConsumer.js'); +const { + localServiceManagerLegacyRollbackPreparationPath, + prepareLocalServiceManagerLegacyRollback, +} = require('../dist/deployment/service-manager/serviceLegacyRollback.js'); const { prepareLocalServiceManagerIntent, } = require('../dist/deployment/service-manager/serviceManagerIntent.js'); @@ -163,8 +167,10 @@ function fixture(t) { cutoverId, activationDigest, commitmentDigest, + commitmentPath, applicationPath, sourcePath, + targetPath, identity, procRoot, }; @@ -315,6 +321,69 @@ function consumeCommand(state, prepared) { }; } +async function stopAdoptedTarget(state, suffix = '031') { + const active = prepare( + state, + 1, + 'install-enable-start', + state.commitmentDigest, + `123e4567-e89b-42d3-a456-426614174${suffix}`, + ); + publishOutcome(active, 'install-enable-start', 'active', 4723, 1786416000200); + const startupReceiptDigest = publishReceipt(state, 4723, '100007'); + const activeResult = await consumeLocalServiceManagerCutoverOutcome( + consumeCommand(state, active), + { procRoot: state.procRoot }, + ); + const stopped = prepare( + state, + 1, + 'stop', + activeResult.recordDigest, + `123e4567-e89b-42d3-a456-426614174${String(Number(suffix) + 1).padStart( + 3, + '0', + )}`, + ); + publishOutcome(stopped, 'stop', 'stopped', 0, 1786416000300); + publishShutdownReceipt(state, 4723, '100007', startupReceiptDigest); + fs.rmSync(path.join(state.procRoot, '4723'), { + recursive: true, + force: true, + }); + const stoppedResult = await consumeLocalServiceManagerCutoverOutcome( + consumeCommand(state, stopped), + { procRoot: state.procRoot }, + ); + const head = readLocalCutoverInstanceHead( + state.root, + 'edge-router-1', + process.getuid(), + ); + return { stoppedResult, head }; +} + +function rollbackPrepareCommand(state, stoppedResult, head) { + return { + schemaVersion: 1, + operation: 'local.deployment.service-manager.legacy-rollback.prepare', + options: { + deploymentRoot: state.root, + allowRootService: process.getuid() === 0, + }, + request: { + cutoverId: state.cutoverId, + profile: 'edge', + instanceId: 'edge-router-1', + generation: 1, + expectedActivationDigest: state.activationDigest, + expectedStoppedRecordDigest: stoppedResult.recordDigest, + expectedInstanceHeadDigest: head.headDigest, + requestedAtMs: 1786416000400, + }, + }; +} + test('commits adopted service active evidence and replays from the instance head', async (t) => { const state = fixture(t); const prepared = prepare( @@ -468,6 +537,117 @@ test('stop advances only after the exact receipted process identity disappears', assert.equal(record.evidence.shutdownReceiptDigest, shutdownReceiptDigest); }); +test('prepares and exactly replays lossless service-manager legacy rollback evidence', async (t) => { + const state = fixture(t); + const { stoppedResult, head } = await stopAdoptedTarget(state); + const command = rollbackPrepareCommand(state, stoppedResult, head); + const prepared = prepareLocalServiceManagerLegacyRollback(command); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.state, 'rollback_prepared'); + assert.equal(prepared.rollbackDisposition, 'rollback_candidate'); + assert.match(prepared.preparationDigest, /^[0-9a-f]{64}$/); + const preparedHead = readLocalCutoverInstanceHead( + state.root, + 'edge-router-1', + process.getuid(), + ); + assert.equal(preparedHead.state, 'rollback_prepared'); + assert.equal(preparedHead.sourceRecordDigest, prepared.preparationDigest); + const record = JSON.parse( + fs.readFileSync( + localServiceManagerLegacyRollbackPreparationPath( + state.root, + state.cutoverId, + 1, + ), + 'utf8', + ), + ); + assert.equal(record.stoppedRecordDigest, stoppedResult.recordDigest); + assert.equal(record.reconciliation.targetMatchesActivation, true); + assert.equal(record.reconciliation.sourceMatchesRecovery, true); + assert.equal(record.reconciliation.targetSidecarsClear, true); + assert.equal(record.reconciliation.sourceSidecarsClear, true); + const replay = prepareLocalServiceManagerLegacyRollback(command); + assert.equal(replay.status, 'existing'); + assert.equal(replay.preparationDigest, prepared.preparationDigest); + assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest); +}); + +test('keeps target_stopped when service-manager rollback would discard target writes', async (t) => { + const state = fixture(t); + const { stoppedResult, head } = await stopAdoptedTarget(state, '033'); + fs.writeFileSync(state.targetPath, 'target-written-by-qinglong3\n', { + mode: 0o600, + }); + const result = prepareLocalServiceManagerLegacyRollback( + rollbackPrepareCommand(state, stoppedResult, head), + ); + assert.equal(result.status, 'not-prepared'); + assert.equal(result.state, 'target_stopped'); + assert.equal(result.rollbackDisposition, 'reconciliation_required'); + assert.equal(result.preparationDigest, '0'.repeat(64)); + const unchanged = readLocalCutoverInstanceHead( + state.root, + 'edge-router-1', + process.getuid(), + ); + assert.equal(unchanged.state, 'target_stopped'); + assert.equal(unchanged.headDigest, head.headDigest); + assert.equal( + fs.existsSync( + localServiceManagerLegacyRollbackPreparationPath( + state.root, + state.cutoverId, + 1, + ), + ), + false, + ); +}); + +test('rejects application configuration drift before service-manager rollback preparation', async (t) => { + const state = fixture(t); + const { stoppedResult, head } = await stopAdoptedTarget(state, '035'); + fs.appendFileSync(state.applicationPath, ' '); + assert.throws( + () => + prepareLocalServiceManagerLegacyRollback( + rollbackPrepareCommand(state, stoppedResult, head), + ), + /application configuration digest drifted/, + ); + const unchanged = readLocalCutoverInstanceHead( + state.root, + 'edge-router-1', + process.getuid(), + ); + assert.equal(unchanged.state, 'target_stopped'); + assert.equal(unchanged.headDigest, head.headDigest); +}); + +test('rejects legacy silence commitment drift before service-manager rollback preparation', async (t) => { + const state = fixture(t); + const { stoppedResult, head } = await stopAdoptedTarget(state, '037'); + const commitment = JSON.parse(fs.readFileSync(state.commitmentPath, 'utf8')); + commitment.observedAtMs += 1; + writePrivate(state.commitmentPath, commitment); + assert.throws( + () => + prepareLocalServiceManagerLegacyRollback( + rollbackPrepareCommand(state, stoppedResult, head), + ), + /application rollback binding drifted/, + ); + const unchanged = readLocalCutoverInstanceHead( + state.root, + 'edge-router-1', + process.getuid(), + ); + assert.equal(unchanged.state, 'target_stopped'); + assert.equal(unchanged.headDigest, head.headDigest); +}); + test('stop without an exact shutdown receipt requires manual resolution', async (t) => { const state = fixture(t); const first = prepare( diff --git a/scripts/ql3-service-manager-bridge-docker-gate.cjs b/scripts/ql3-service-manager-bridge-docker-gate.cjs index 4477f468..a5d61126 100644 --- a/scripts/ql3-service-manager-bridge-docker-gate.cjs +++ b/scripts/ql3-service-manager-bridge-docker-gate.cjs @@ -81,6 +81,9 @@ function actorReport(name, kind, identityMode) { 'serviceProcessIdentity', 'adoptedCutoverActive', 'adoptedCutoverStopped', + 'adoptedRollbackPrepared', + 'adoptedRollbackReplay', + 'adoptedRollbackCandidate', ]; if ( report.managerKind !== kind || @@ -192,6 +195,15 @@ function main() { adoptedCutoverStopped: reports.every( (report) => report.gates.adoptedCutoverStopped, ), + adoptedRollbackPrepared: reports.every( + (report) => report.gates.adoptedRollbackPrepared, + ), + adoptedRollbackReplay: reports.every( + (report) => report.gates.adoptedRollbackReplay, + ), + adoptedRollbackCandidate: reports.every( + (report) => report.gates.adoptedRollbackCandidate, + ), systemdRestart: reports.some( (report) => report.managerKind === 'systemd' && report.gates.restarted, diff --git a/scripts/ql3-service-manager-bridge-live-actor.cjs b/scripts/ql3-service-manager-bridge-live-actor.cjs index be5256c8..fc697634 100644 --- a/scripts/ql3-service-manager-bridge-live-actor.cjs +++ b/scripts/ql3-service-manager-bridge-live-actor.cjs @@ -368,7 +368,12 @@ function adoptedFixture(root, kind, uid, gid) { uid, gid, ); - return { cutoverId, activationDigest, commitmentDigest }; + return { + cutoverId, + instanceId: commitmentPayload.instanceId, + activationDigest, + commitmentDigest, + }; } async function consumeCutover(root, prepared, uid, gid) { @@ -401,6 +406,36 @@ async function consumeCutover(root, prepared, uid, gid) { return ownerCli(uid, gid, 'service-cutover-consume', commandPath); } +function prepareAdoptedRollback(root, adopted, stopped, uid, gid) { + const command = { + schemaVersion: 1, + operation: 'local.deployment.service-manager.legacy-rollback.prepare', + options: { deploymentRoot: root, allowRootService: uid === 0 }, + request: { + cutoverId: adopted.cutoverId, + profile: 'edge', + instanceId: adopted.instanceId, + generation: 1, + expectedActivationDigest: adopted.activationDigest, + expectedStoppedRecordDigest: stopped.recordDigest, + expectedInstanceHeadDigest: stopped.instanceHeadDigest, + requestedAtMs: Date.now(), + }, + }; + const commandPath = path.join(root, 'owner-legacy-rollback-prepare.json'); + writePrivate( + commandPath, + `${JSON.stringify(command, null, 2)}\n`, + 0o600, + uid, + gid, + ); + return { + commandPath, + result: ownerCli(uid, gid, 'service-legacy-rollback-prepare', commandPath), + }; +} + function execute(root, controllerRoot, managerOptions, prepared, uid, gid) { const command = { schemaVersion: 1, @@ -670,6 +705,29 @@ async function main(argv) { ) { fail('adopted service did not commit target_stopped'); } + const adoptedRollback = prepareAdoptedRollback( + root, + adopted, + adoptedStopped, + uid, + gid, + ); + const adoptedRollbackReplay = ownerCli( + uid, + gid, + 'service-legacy-rollback-prepare', + adoptedRollback.commandPath, + ); + if ( + adoptedRollback.result.status !== 'prepared' || + adoptedRollback.result.state !== 'rollback_prepared' || + adoptedRollback.result.rollbackDisposition !== 'rollback_candidate' || + adoptedRollbackReplay.status !== 'existing' || + adoptedRollbackReplay.preparationDigest !== + adoptedRollback.result.preparationDigest + ) { + fail('adopted rollback preparation did not converge exactly'); + } const payload = { schemaVersion: 1, evidenceClass: 'qinglong3_service_manager_bridge_live_actor', @@ -686,6 +744,8 @@ async function main(argv) { adoptedCutover: { active: adoptedActive, stopped: adoptedStopped, + rollbackPrepared: adoptedRollback.result, + rollbackReplay: adoptedRollbackReplay, }, gates: { rootCommandFile: true, @@ -699,6 +759,9 @@ async function main(argv) { serviceProcessIdentity: true, adoptedCutoverActive: true, adoptedCutoverStopped: true, + adoptedRollbackPrepared: true, + adoptedRollbackReplay: true, + adoptedRollbackCandidate: true, }, }; process.stdout.write( diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index e6589cef..2462098c 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: 107, + sourceFiles: 108, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 106, + nestedSourceFiles: 107, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );