mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prepare service manager legacy rollback
This commit is contained in:
@@ -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,尤其
|
||||
|
||||
@@ -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 <private.json>
|
||||
```
|
||||
|
||||
命令必须提交 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 已全部完成。
|
||||
@@ -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 |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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<LocalDeploymentTargetRunCommand>,
|
||||
uid: number,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
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<TargetDataReconciliationInput>,
|
||||
uid: number,
|
||||
): Readonly<TargetDataReconciliationEvidence> {
|
||||
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',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -36,6 +37,7 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
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<void> {
|
||||
? 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' ||
|
||||
|
||||
@@ -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<TargetDataReconciliationEvidence>;
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
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<LocalServiceManagerLegacyRollbackPrepareCommand> {
|
||||
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<LocalServiceManagerLegacyRollbackPrepareCommand>,
|
||||
): 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<LocalServiceManagerLegacyRollbackPrepareCommand>,
|
||||
expectedApplicationDigest: string,
|
||||
expectedCommitmentDigest: string,
|
||||
): Readonly<AdoptedPaths> {
|
||||
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<LocalServiceManagerLegacyRollbackPrepareCommand>,
|
||||
stopped: ReturnType<typeof normalizeLocalServiceManagerCutoverRecord>,
|
||||
reconciliation: Readonly<TargetDataReconciliationEvidence>,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
|
||||
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<LocalServiceManagerLegacyRollbackPreparation> {
|
||||
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<LocalServiceManagerLegacyRollbackPrepareCommand>,
|
||||
status: LocalServiceManagerLegacyRollbackPrepareResult['status'],
|
||||
state: LocalServiceManagerLegacyRollbackPrepareResult['state'],
|
||||
reconciliation: Readonly<TargetDataReconciliationEvidence>,
|
||||
preparationDigest: string,
|
||||
instanceHeadDigest: string,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackPrepareResult> {
|
||||
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<LocalServiceManagerLegacyRollbackPrepareResult> {
|
||||
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<LocalServiceManagerLegacyRollbackPrepareResult> {
|
||||
return prepareLocalServiceManagerLegacyRollback(
|
||||
readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user