mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): prepare service manager legacy rollback
This commit is contained in:
@@ -11,6 +11,25 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-20):
|
最新增量证据(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
|
- 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、单日志、日志列表和
|
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,尤其
|
单实例 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-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-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-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;
|
readonly evidenceDigest: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TargetDataReconciliationInput {
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly activationPath: string;
|
||||||
|
readonly legacySourcePath: string;
|
||||||
|
readonly targetDatabasePath: string;
|
||||||
|
readonly expectedActivationDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FileEvidence {
|
interface FileEvidence {
|
||||||
readonly sha256: string;
|
readonly sha256: string;
|
||||||
readonly identityDigest: string;
|
readonly identityDigest: string;
|
||||||
@@ -150,22 +158,36 @@ function evidence(
|
|||||||
export function readTargetDataReconciliationEvidence(
|
export function readTargetDataReconciliationEvidence(
|
||||||
command: Readonly<LocalDeploymentTargetRunCommand>,
|
command: Readonly<LocalDeploymentTargetRunCommand>,
|
||||||
uid: number,
|
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> {
|
): Readonly<TargetDataReconciliationEvidence> {
|
||||||
try {
|
try {
|
||||||
const activation = object(
|
const activation = object(
|
||||||
readPrivateLocalCommandFile(command.request.activationPath),
|
readPrivateLocalCommandFile(input.activationPath),
|
||||||
);
|
);
|
||||||
const { activationDigest, ...payload } = activation;
|
const { activationDigest, ...payload } = activation;
|
||||||
if (
|
if (
|
||||||
activation.schemaVersion !== 1 ||
|
activation.schemaVersion !== 1 ||
|
||||||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
activation.kind !== 'qinglong3-local-sqlite-activation' ||
|
||||||
activation.state !== 'prepared' ||
|
activation.state !== 'prepared' ||
|
||||||
activation.profile !== command.request.profile ||
|
activation.profile !== input.profile ||
|
||||||
activation.sourcePathDigest !==
|
activation.sourcePathDigest !== textDigest(input.legacySourcePath) ||
|
||||||
textDigest(command.request.legacySourcePath) ||
|
activation.targetPathDigest !== textDigest(input.targetDatabasePath) ||
|
||||||
activation.targetPathDigest !==
|
activationDigest !== input.expectedActivationDigest ||
|
||||||
textDigest(command.request.targetDatabasePath) ||
|
|
||||||
activationDigest !== command.request.expectedActivationDigest ||
|
|
||||||
typeof activationDigest !== 'string' ||
|
typeof activationDigest !== 'string' ||
|
||||||
!DIGEST_PATTERN.test(activationDigest) ||
|
!DIGEST_PATTERN.test(activationDigest) ||
|
||||||
typeof activation.targetSha256 !== 'string' ||
|
typeof activation.targetSha256 !== 'string' ||
|
||||||
@@ -179,12 +201,12 @@ export function readTargetDataReconciliationEvidence(
|
|||||||
throw new Error('activation identity drifted');
|
throw new Error('activation identity drifted');
|
||||||
}
|
}
|
||||||
const target = fileEvidence(
|
const target = fileEvidence(
|
||||||
command.request.targetDatabasePath,
|
input.targetDatabasePath,
|
||||||
uid,
|
uid,
|
||||||
'target database',
|
'target database',
|
||||||
);
|
);
|
||||||
const source = fileEvidence(
|
const source = fileEvidence(
|
||||||
command.request.legacySourcePath,
|
input.legacySourcePath,
|
||||||
uid,
|
uid,
|
||||||
'legacy source database',
|
'legacy source database',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ import {
|
|||||||
consumeLocalServiceManagerCutoverOutcome,
|
consumeLocalServiceManagerCutoverOutcome,
|
||||||
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
||||||
} from './service-manager/serviceCutoverConsumer';
|
} from './service-manager/serviceCutoverConsumer';
|
||||||
|
import {
|
||||||
|
prepareLocalServiceManagerLegacyRollback,
|
||||||
|
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
||||||
|
} from './service-manager/serviceLegacyRollback';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
LocalDeploymentConfigurationError,
|
LocalDeploymentConfigurationError,
|
||||||
@@ -181,6 +185,8 @@ export {
|
|||||||
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
||||||
prepareLocalServiceManagerIntent,
|
prepareLocalServiceManagerIntent,
|
||||||
prepareLocalServiceManagerIntentCommandFile,
|
prepareLocalServiceManagerIntentCommandFile,
|
||||||
|
prepareLocalServiceManagerLegacyRollback,
|
||||||
|
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
||||||
};
|
};
|
||||||
export {
|
export {
|
||||||
localServiceManagerIntentDigest,
|
localServiceManagerIntentDigest,
|
||||||
@@ -209,6 +215,13 @@ export {
|
|||||||
type LocalServiceManagerCutoverRecord,
|
type LocalServiceManagerCutoverRecord,
|
||||||
type LocalServiceManagerCutoverState,
|
type LocalServiceManagerCutoverState,
|
||||||
} from './service-manager/serviceCutoverJournal';
|
} from './service-manager/serviceCutoverJournal';
|
||||||
|
export {
|
||||||
|
localServiceManagerLegacyRollbackPreparationPath,
|
||||||
|
normalizeLocalServiceManagerLegacyRollbackPreparation,
|
||||||
|
type LocalServiceManagerLegacyRollbackPreparation,
|
||||||
|
type LocalServiceManagerLegacyRollbackPrepareCommand,
|
||||||
|
type LocalServiceManagerLegacyRollbackPrepareResult,
|
||||||
|
} from './service-manager/serviceLegacyRollback';
|
||||||
|
|
||||||
export async function prepareLocalDeployment(
|
export async function prepareLocalDeployment(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
inspectLocalDeploymentStatusCommandFile,
|
inspectLocalDeploymentStatusCommandFile,
|
||||||
preflightLocalDeploymentComposeCommandFile,
|
preflightLocalDeploymentComposeCommandFile,
|
||||||
prepareLocalServiceManagerIntentCommandFile,
|
prepareLocalServiceManagerIntentCommandFile,
|
||||||
|
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
||||||
prepareLocalDeploymentCommandFile,
|
prepareLocalDeploymentCommandFile,
|
||||||
restoreLocalDeploymentComposeCommitCommandFile,
|
restoreLocalDeploymentComposeCommitCommandFile,
|
||||||
restoreLocalDeploymentComposePrepareCommandFile,
|
restoreLocalDeploymentComposePrepareCommandFile,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
} from './localDeployment';
|
} from './localDeployment';
|
||||||
|
|
||||||
const USAGE =
|
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> {
|
async function main(argv: readonly string[]): Promise<void> {
|
||||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
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-intent-prepare' &&
|
||||||
argv[0] !== 'service-outcome-consume' &&
|
argv[0] !== 'service-outcome-consume' &&
|
||||||
argv[0] !== 'service-cutover-consume' &&
|
argv[0] !== 'service-cutover-consume' &&
|
||||||
|
argv[0] !== 'service-legacy-rollback-prepare' &&
|
||||||
argv[0] !== 'cutover-legacy-stop' &&
|
argv[0] !== 'cutover-legacy-stop' &&
|
||||||
argv[0] !== 'cutover-target-start' &&
|
argv[0] !== 'cutover-target-start' &&
|
||||||
argv[0] !== 'cutover-target-restart' &&
|
argv[0] !== 'cutover-target-restart' &&
|
||||||
@@ -74,6 +76,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? consumeLocalServiceManagerOutcomeCommandFile(argv[2]!)
|
? consumeLocalServiceManagerOutcomeCommandFile(argv[2]!)
|
||||||
: argv[0] === 'service-cutover-consume'
|
: argv[0] === 'service-cutover-consume'
|
||||||
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
|
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'service-legacy-rollback-prepare'
|
||||||
|
? prepareLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
|
||||||
: argv[0] === 'cutover-legacy-stop'
|
: argv[0] === 'cutover-legacy-stop'
|
||||||
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
|
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
|
||||||
: argv[0] === 'cutover-target-start' ||
|
: 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 {
|
const {
|
||||||
consumeLocalServiceManagerCutoverOutcome,
|
consumeLocalServiceManagerCutoverOutcome,
|
||||||
} = require('../dist/deployment/service-manager/serviceCutoverConsumer.js');
|
} = require('../dist/deployment/service-manager/serviceCutoverConsumer.js');
|
||||||
|
const {
|
||||||
|
localServiceManagerLegacyRollbackPreparationPath,
|
||||||
|
prepareLocalServiceManagerLegacyRollback,
|
||||||
|
} = require('../dist/deployment/service-manager/serviceLegacyRollback.js');
|
||||||
const {
|
const {
|
||||||
prepareLocalServiceManagerIntent,
|
prepareLocalServiceManagerIntent,
|
||||||
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
|
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
|
||||||
@@ -163,8 +167,10 @@ function fixture(t) {
|
|||||||
cutoverId,
|
cutoverId,
|
||||||
activationDigest,
|
activationDigest,
|
||||||
commitmentDigest,
|
commitmentDigest,
|
||||||
|
commitmentPath,
|
||||||
applicationPath,
|
applicationPath,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
identity,
|
identity,
|
||||||
procRoot,
|
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) => {
|
test('commits adopted service active evidence and replays from the instance head', async (t) => {
|
||||||
const state = fixture(t);
|
const state = fixture(t);
|
||||||
const prepared = prepare(
|
const prepared = prepare(
|
||||||
@@ -468,6 +537,117 @@ test('stop advances only after the exact receipted process identity disappears',
|
|||||||
assert.equal(record.evidence.shutdownReceiptDigest, shutdownReceiptDigest);
|
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) => {
|
test('stop without an exact shutdown receipt requires manual resolution', async (t) => {
|
||||||
const state = fixture(t);
|
const state = fixture(t);
|
||||||
const first = prepare(
|
const first = prepare(
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ function actorReport(name, kind, identityMode) {
|
|||||||
'serviceProcessIdentity',
|
'serviceProcessIdentity',
|
||||||
'adoptedCutoverActive',
|
'adoptedCutoverActive',
|
||||||
'adoptedCutoverStopped',
|
'adoptedCutoverStopped',
|
||||||
|
'adoptedRollbackPrepared',
|
||||||
|
'adoptedRollbackReplay',
|
||||||
|
'adoptedRollbackCandidate',
|
||||||
];
|
];
|
||||||
if (
|
if (
|
||||||
report.managerKind !== kind ||
|
report.managerKind !== kind ||
|
||||||
@@ -192,6 +195,15 @@ function main() {
|
|||||||
adoptedCutoverStopped: reports.every(
|
adoptedCutoverStopped: reports.every(
|
||||||
(report) => report.gates.adoptedCutoverStopped,
|
(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(
|
systemdRestart: reports.some(
|
||||||
(report) =>
|
(report) =>
|
||||||
report.managerKind === 'systemd' && report.gates.restarted,
|
report.managerKind === 'systemd' && report.gates.restarted,
|
||||||
|
|||||||
@@ -368,7 +368,12 @@ function adoptedFixture(root, kind, uid, gid) {
|
|||||||
uid,
|
uid,
|
||||||
gid,
|
gid,
|
||||||
);
|
);
|
||||||
return { cutoverId, activationDigest, commitmentDigest };
|
return {
|
||||||
|
cutoverId,
|
||||||
|
instanceId: commitmentPayload.instanceId,
|
||||||
|
activationDigest,
|
||||||
|
commitmentDigest,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function consumeCutover(root, prepared, uid, gid) {
|
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);
|
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) {
|
function execute(root, controllerRoot, managerOptions, prepared, uid, gid) {
|
||||||
const command = {
|
const command = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -670,6 +705,29 @@ async function main(argv) {
|
|||||||
) {
|
) {
|
||||||
fail('adopted service did not commit target_stopped');
|
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 = {
|
const payload = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
evidenceClass: 'qinglong3_service_manager_bridge_live_actor',
|
evidenceClass: 'qinglong3_service_manager_bridge_live_actor',
|
||||||
@@ -686,6 +744,8 @@ async function main(argv) {
|
|||||||
adoptedCutover: {
|
adoptedCutover: {
|
||||||
active: adoptedActive,
|
active: adoptedActive,
|
||||||
stopped: adoptedStopped,
|
stopped: adoptedStopped,
|
||||||
|
rollbackPrepared: adoptedRollback.result,
|
||||||
|
rollbackReplay: adoptedRollbackReplay,
|
||||||
},
|
},
|
||||||
gates: {
|
gates: {
|
||||||
rootCommandFile: true,
|
rootCommandFile: true,
|
||||||
@@ -699,6 +759,9 @@ async function main(argv) {
|
|||||||
serviceProcessIdentity: true,
|
serviceProcessIdentity: true,
|
||||||
adoptedCutoverActive: true,
|
adoptedCutoverActive: true,
|
||||||
adoptedCutoverStopped: true,
|
adoptedCutoverStopped: true,
|
||||||
|
adoptedRollbackPrepared: true,
|
||||||
|
adoptedRollbackReplay: true,
|
||||||
|
adoptedRollbackCandidate: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 107,
|
sourceFiles: 108,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 106,
|
nestedSourceFiles: 107,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user