mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): commit service manager legacy rollback
This commit is contained in:
@@ -11,6 +11,26 @@
|
||||
|
||||
最新增量证据(2026-08-20):
|
||||
|
||||
- D-380/ADR-0473(已接受;OpenRC live actor 待镜像基础设施恢复后补跑):完成 systemd/OpenRC service-manager rollback 的
|
||||
安全 commit 协议。`rollback_prepared` 不再被误当作 root 授权;Owner 重新绑定当前 head、preparation、Application 与
|
||||
legacy-silence commitment 原始摘要、descriptor 摘要后,才 no-replace 发布 `legacy_restart_requested`。短生命周期 root bridge
|
||||
只允许固定 `qinglong`/`qinglong3`、固定 descriptor 与固定 systemd/OpenRC argv,启动前证明双服务 inactive,并在任何 start 前写
|
||||
durable barrier;barrier 后所有重放只能 inspect,响应丢失可按 legacy active/target inactive 与进程身份收敛,无法证明则由 Owner
|
||||
消费为 `manual_required`,永不盲目二次 start。Owner 是 instance head 的唯一终态写者,成功 CAS 到 `legacy_running` 并支持
|
||||
exact replay;该状态只证明 init/process running 与 target stopped,不宣称 2.x 业务健康。实现仍在现有 Local Owner package,且将
|
||||
回滚四模块内聚到 `service-manager/legacy-rollback/`,没有新 package、production dependency、binary、daemon、watcher、timer、
|
||||
socket、数据库连接或部署对象。聚焦门 `16/16`;Local Owner 全量
|
||||
`181 total / 176 pass / 5 conditional skip / 0 fail`;backend 全量
|
||||
`1,523 total / 1,521 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 为
|
||||
`111 source / 110 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。systemd live actor 已覆盖
|
||||
root/non-root success、真实 manager start、response-loss inspect convergence、Owner consume/replay;root barrier-crash 证明没有
|
||||
第二次 start,双服务均 inactive 并收敛为 manual。OpenRC 因本机缺少基础镜像且 `node:24-alpine` credential helper 挂起未执行,
|
||||
不能宣称全组合门已闭合;临时容器/镜像/tag 已清理。本切片不改变 PostgreSQL schema、ACL、repository、role、Pool、连接或
|
||||
failover 语义,因此不重跑且不重新占有 HA 证明。D-381 应补 OpenRC 证据与 `legacy_running` 后的有界 2.x readiness/health proof。
|
||||
- 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。
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADR-0473:Service Manager Legacy Rollback Commit
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-20
|
||||
- 关联 RFC:QL-RFC-0001 D-64、D-274、D-275、D-379、D-380
|
||||
- 关联 ADR:ADR-0314、ADR-0315、ADR-0362、ADR-0363、ADR-0472
|
||||
|
||||
## 背景
|
||||
|
||||
ADR-0472 已使 systemd/OpenRC adopted cutover 能在 Owner authority 内把安全候选推进到
|
||||
`rollback_prepared`,但它刻意不授予 root mutation,也不能证明旧服务已经重新运行。若 root 直接把
|
||||
`rollback_prepared` 当作启动授权,Owner 的数据判断、init mutation 与最终实例状态会混成一个不可审计步骤;若在响应丢失后
|
||||
再次执行 `start`,又可能越过崩溃窗口重复副作用。
|
||||
|
||||
本阶段还必须同时适配低配路由设备和集群节点:回滚不能引入新 daemon、watcher、连接池或部署对象,也不能为了源码目录变密就
|
||||
制造单文件 workspace package。编辑前 GitNexus 对 preparation、service bridge、deployment CLI、live actor 与 Docker gate
|
||||
涉及的既有符号完成 upstream impact;均为 LOW,最多 3 个直接/累计上游、0 条已识别 execution flow。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. Owner 授权与 root mutation 分离
|
||||
|
||||
Owner 只能通过私有命令文件执行:
|
||||
|
||||
```text
|
||||
ql3-local-deploy service-legacy-rollback-authorize --command-file <private.json>
|
||||
```
|
||||
|
||||
授权必须重新验证当前 instance head、`rollback_prepared` record、Application 原始字节摘要、legacy-silence commitment 原始字节
|
||||
摘要、目标 descriptor 摘要和 manager kind,并 no-replace 发布 `legacy_restart_requested`。preparation 不是 root 授权,root bridge
|
||||
只接受这一种当前 head 和 exact authorization digest。
|
||||
|
||||
### 2. root bridge 只允许固定服务和固定 argv
|
||||
|
||||
root bridge 的唯一新操作是 `local.deployment.service-manager.legacy-rollback.execute`。它只允许:
|
||||
|
||||
- legacy 服务名 `qinglong` 与 target 服务名 `qinglong3`;
|
||||
- systemd descriptor `/etc/systemd/system/qinglong.service` 或 OpenRC descriptor `/etc/init.d/qinglong`;
|
||||
- 受审的固定 `systemctl` 或 `rc-service` argv,不接受 shell、任意 executable、服务名或路径;
|
||||
- 启动前同时证明 legacy inactive、target inactive,并复验 Owner application/commitment/authorization 与 descriptor 原始摘要。
|
||||
|
||||
bridge 在任何 start 前先 no-replace 写 durable root barrier。barrier 之后的所有重放都只能 inspect,永不再次 start。
|
||||
|
||||
### 3. 响应丢失按双服务观察收敛
|
||||
|
||||
若 manager 实际完成 start 但响应丢失,重放读取 barrier 并检查两个固定服务:只有 legacy active、target inactive,且进程身份与
|
||||
manager 观察一致时,root outcome 才是 proved running。若 barrier 已存在但无法证明上述事实,结果固定为 unproved,交给 Owner
|
||||
终结为 `manual_required`;不得猜测、删除 barrier、替换 descriptor 或自动启动 target。
|
||||
|
||||
root outcome 先于 Owner outcome 持久化,因此 root 成功后 Owner 进程崩溃仍可用相同输入恢复,不重复 init mutation。
|
||||
|
||||
### 4. Owner 是实例终态唯一写者
|
||||
|
||||
Owner 通过:
|
||||
|
||||
```text
|
||||
ql3-local-deploy service-legacy-rollback-consume --command-file <private.json>
|
||||
```
|
||||
|
||||
消费 exact root outcome。只有 root 证明 legacy running、target stopped,且 authorization/head/digest 全部一致时,才 CAS 到
|
||||
`legacy_running`;任一不确定、漂移或双服务冲突都进入 `manual_required`。exact replay 返回同一 completion,不覆盖已存在的不同
|
||||
Owner outcome。
|
||||
|
||||
`legacy_running` 只证明 init/process running 与 target stopped,不代表 QingLong 2.x HTTP、任务调度、数据库或业务健康。
|
||||
|
||||
### 5. 保持部署与 package 边界
|
||||
|
||||
实现留在现有 `@qinglong/local-owner-cli`。为避免 `service-manager/` 继续平铺超过直接源码文件阈值,回滚子域内聚为:
|
||||
|
||||
```text
|
||||
service-manager/legacy-rollback/
|
||||
preparation.ts
|
||||
contract.ts
|
||||
consumer.ts
|
||||
bridge.ts
|
||||
```
|
||||
|
||||
不新增 workspace package、production dependency、binary、daemon、timer、watcher、socket、数据库连接、systemd/OpenRC unit 或
|
||||
Kubernetes workload。root bridge 仍是显式短生命周期命令。
|
||||
|
||||
## 被否决方案
|
||||
|
||||
1. **把 `rollback_prepared` 直接交给 root**:准备事实不是启动授权,拒绝。
|
||||
2. **barrier 重放再次调用 start**:响应丢失会重复副作用,拒绝。
|
||||
3. **root 直接写 instance head**:会混淆 OS mutation 与 Owner 状态 authority,拒绝。
|
||||
4. **只检查 legacy active**:target 同时运行时会制造双主,拒绝。
|
||||
5. **把 `legacy_running` 宣称为业务健康**:init/process 观察不足以证明 2.x readiness,拒绝。
|
||||
6. **新建 rollback package 或 daemon**:没有独立交付/依赖/生命周期依据,并放大路由设备闭包,拒绝。
|
||||
|
||||
## 验收证据
|
||||
|
||||
- 聚焦 Owner/contract 门 `16/16`;Local Owner 全量 `181 total / 176 pass / 5 conditional skip / 0 fail`。
|
||||
- 18-package clean build/逐包测试单次退出 0;backend 全量
|
||||
`1,523 total / 1,521 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 为 `111 source / 110 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。回滚 authority 未进入基础运行闭包。
|
||||
- systemd live actor 已覆盖 root/non-root 成功、真实 manager start、响应丢失后的 inspect-only 收敛、Owner
|
||||
`legacy_running` 消费与 exact replay;root barrier-crash 场景证明没有第二次 start,legacy/target 均保持 inactive,Owner 收敛为
|
||||
`manual_required`。
|
||||
- OpenRC actor 因本机缺少 Alpine/OpenRC 基础镜像,拉取 `node:24-alpine` 时 credential helper 挂起而未执行;因此本 ADR
|
||||
不宣称 systemd/OpenRC 全组合门闭合。临时 systemd 容器、测试镜像和临时 tag 均已清理。
|
||||
- 本阶段不修改 SQL、migration、PostgreSQL repository/role/Pool 或 HA 语义,因此不重新生成 PostgreSQL HA 证据。
|
||||
|
||||
## 未完成
|
||||
|
||||
- OpenRC root/non-root success 与 root barrier-crash live actor;
|
||||
- `legacy_running` 后有界、可重放的 2.x readiness/health proof;
|
||||
- 固定物理 Edge 的完整 prepare/authorize/execute/consume 回滚证据;
|
||||
- `reconciliation_required` 的 export、冲突裁决与受审回灌。
|
||||
|
||||
本 ADR 关闭 service-manager 回滚的安全 commit 与进程级收敛,不代表 QingLong 3.0 升级/回退 Gate 已全部完成。
|
||||
@@ -476,6 +476,7 @@
|
||||
| [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 |
|
||||
| [ADR-0473](./ADR-0473-service-manager-legacy-rollback-commit.md) | Service Manager Legacy Rollback Commit | Accepted(OpenRC live actor 待补) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -82,7 +82,13 @@ import {
|
||||
import {
|
||||
prepareLocalServiceManagerLegacyRollback,
|
||||
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
||||
} from './service-manager/serviceLegacyRollback';
|
||||
} from './service-manager/legacy-rollback/preparation';
|
||||
import {
|
||||
authorizeLocalServiceManagerLegacyRollback,
|
||||
authorizeLocalServiceManagerLegacyRollbackCommandFile,
|
||||
consumeLocalServiceManagerLegacyRollback,
|
||||
consumeLocalServiceManagerLegacyRollbackCommandFile,
|
||||
} from './service-manager/legacy-rollback/consumer';
|
||||
|
||||
export {
|
||||
LocalDeploymentConfigurationError,
|
||||
@@ -187,6 +193,10 @@ export {
|
||||
prepareLocalServiceManagerIntentCommandFile,
|
||||
prepareLocalServiceManagerLegacyRollback,
|
||||
prepareLocalServiceManagerLegacyRollbackCommandFile,
|
||||
authorizeLocalServiceManagerLegacyRollback,
|
||||
authorizeLocalServiceManagerLegacyRollbackCommandFile,
|
||||
consumeLocalServiceManagerLegacyRollback,
|
||||
consumeLocalServiceManagerLegacyRollbackCommandFile,
|
||||
};
|
||||
export {
|
||||
localServiceManagerIntentDigest,
|
||||
@@ -221,7 +231,29 @@ export {
|
||||
type LocalServiceManagerLegacyRollbackPreparation,
|
||||
type LocalServiceManagerLegacyRollbackPrepareCommand,
|
||||
type LocalServiceManagerLegacyRollbackPrepareResult,
|
||||
} from './service-manager/serviceLegacyRollback';
|
||||
} from './service-manager/legacy-rollback/preparation';
|
||||
export {
|
||||
normalizeLocalServiceManagerLegacyRollbackCompletion,
|
||||
type LocalServiceManagerLegacyRollbackAuthorizeCommand,
|
||||
type LocalServiceManagerLegacyRollbackAuthorizeResult,
|
||||
type LocalServiceManagerLegacyRollbackCompletion,
|
||||
type LocalServiceManagerLegacyRollbackConsumeCommand,
|
||||
type LocalServiceManagerLegacyRollbackConsumeResult,
|
||||
} from './service-manager/legacy-rollback/consumer';
|
||||
export {
|
||||
localServiceManagerLegacyCompletionPath,
|
||||
localServiceManagerLegacyDescriptorPath,
|
||||
localServiceManagerLegacyStartAuthorizationPath,
|
||||
localServiceManagerLegacyStartOutcomePath,
|
||||
localServiceManagerTargetDescriptorPath,
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand,
|
||||
normalizeLocalServiceManagerLegacyStartAuthorization,
|
||||
normalizeLocalServiceManagerLegacyStartOutcome,
|
||||
type LocalServiceManagerLegacyRollbackBridgeCommand,
|
||||
type LocalServiceManagerLegacyRollbackBridgeResult,
|
||||
type LocalServiceManagerLegacyStartAuthorization,
|
||||
type LocalServiceManagerLegacyStartOutcome,
|
||||
} from './service-manager/legacy-rollback/contract';
|
||||
|
||||
export async function prepareLocalDeployment(
|
||||
input: unknown,
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// Keep the deployment binary beside its composition authority.
|
||||
import {
|
||||
applyLocalDeploymentComposeCommandFile,
|
||||
authorizeLocalServiceManagerLegacyRollbackCommandFile,
|
||||
collectLocalDeploymentComposeEvidenceCommitCommandFile,
|
||||
collectLocalDeploymentComposeEvidencePrepareCommandFile,
|
||||
consumeLocalServiceManagerOutcomeCommandFile,
|
||||
consumeLocalServiceManagerCutoverOutcomeCommandFile,
|
||||
consumeLocalServiceManagerLegacyRollbackCommandFile,
|
||||
inspectLocalDeploymentStatusCommandFile,
|
||||
preflightLocalDeploymentComposeCommandFile,
|
||||
prepareLocalServiceManagerIntentCommandFile,
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'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';
|
||||
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-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';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -38,6 +40,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'service-outcome-consume' &&
|
||||
argv[0] !== 'service-cutover-consume' &&
|
||||
argv[0] !== 'service-legacy-rollback-prepare' &&
|
||||
argv[0] !== 'service-legacy-rollback-authorize' &&
|
||||
argv[0] !== 'service-legacy-rollback-consume' &&
|
||||
argv[0] !== 'cutover-legacy-stop' &&
|
||||
argv[0] !== 'cutover-target-start' &&
|
||||
argv[0] !== 'cutover-target-restart' &&
|
||||
@@ -78,6 +82,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? consumeLocalServiceManagerCutoverOutcomeCommandFile(argv[2]!)
|
||||
: argv[0] === 'service-legacy-rollback-prepare'
|
||||
? prepareLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
|
||||
: argv[0] === 'service-legacy-rollback-authorize'
|
||||
? authorizeLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
|
||||
: argv[0] === 'service-legacy-rollback-consume'
|
||||
? consumeLocalServiceManagerLegacyRollbackCommandFile(argv[2]!)
|
||||
: argv[0] === 'cutover-legacy-stop'
|
||||
? stopLegacyDockerForLocalDeploymentCommandFile(argv[2]!)
|
||||
: argv[0] === 'cutover-target-start' ||
|
||||
|
||||
+881
@@ -0,0 +1,881 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import type {
|
||||
LocalServiceBridgeManager,
|
||||
LocalServiceManagerKind,
|
||||
} from '../serviceBridgeContract';
|
||||
import {
|
||||
ensureRootServiceBridgeDirectory,
|
||||
publishServiceBridgeFile,
|
||||
readOwnerPrivateJsonFile,
|
||||
readServiceBridgeFile,
|
||||
validateServiceBridgeDirectory,
|
||||
} from '../serviceBridgeFiles';
|
||||
import type {
|
||||
LocalServiceManagerRunRequest,
|
||||
LocalServiceManagerRunResult,
|
||||
} from '../serviceBridge';
|
||||
import {
|
||||
localServiceManagerLegacyDescriptorPath,
|
||||
localServiceManagerLegacyStartAuthorizationPath,
|
||||
localServiceManagerLegacyStartOutcomeDigest,
|
||||
localServiceManagerLegacyStartOutcomePath,
|
||||
localServiceManagerRollbackObservationDigest,
|
||||
localServiceManagerTargetDescriptorPath,
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand,
|
||||
normalizeLocalServiceManagerLegacyStartAuthorization,
|
||||
normalizeLocalServiceManagerLegacyStartOutcome,
|
||||
normalizeLocalServiceManagerRollbackObservation,
|
||||
type LocalServiceManagerLegacyRollbackBridgeCommand,
|
||||
type LocalServiceManagerLegacyRollbackBridgeResult,
|
||||
type LocalServiceManagerLegacyStartAuthorization,
|
||||
type LocalServiceManagerLegacyStartOutcome,
|
||||
type LocalServiceManagerRollbackManualReason,
|
||||
type LocalServiceManagerRollbackMutationDisposition,
|
||||
type LocalServiceManagerRollbackObservation,
|
||||
} from './contract';
|
||||
|
||||
const BARRIER_SCHEMA = 'qinglong3-local-service-manager-legacy-start-barrier';
|
||||
const MAX_MANAGER_OUTPUT_BYTES = 64 * 1024;
|
||||
const MANAGER_TIMEOUT_MS = 45_000;
|
||||
|
||||
interface LegacyStartBarrier {
|
||||
readonly schema: typeof BARRIER_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly managerKind: LocalServiceManagerKind;
|
||||
readonly preparationDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly ownerUid: number;
|
||||
readonly ownerGid: number;
|
||||
readonly legacyDescriptorDigest: string;
|
||||
readonly targetDescriptorDigest: string;
|
||||
readonly legacyPreObservation: Readonly<LocalServiceManagerRollbackObservation>;
|
||||
readonly targetPreObservation: Readonly<LocalServiceManagerRollbackObservation>;
|
||||
readonly createdAtMs: number;
|
||||
readonly barrierDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackBridgeDependencies {
|
||||
readonly runManager?: (
|
||||
request: Readonly<LocalServiceManagerRunRequest>,
|
||||
) => Readonly<LocalServiceManagerRunResult>;
|
||||
readonly now?: () => number;
|
||||
readonly afterBarrier?: () => void;
|
||||
readonly afterStart?: () => void;
|
||||
}
|
||||
|
||||
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 digest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function assertRootIdentity(): void {
|
||||
if (
|
||||
process.platform === 'win32' ||
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== 0 ||
|
||||
process.geteuid() !== 0
|
||||
) {
|
||||
configurationError('legacy rollback bridge must run as root');
|
||||
}
|
||||
}
|
||||
|
||||
function trustedRootExecutable(filePath: string, label: string): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
configurationError(`${label} is unavailable`, error);
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== 0 ||
|
||||
stat.gid !== 0 ||
|
||||
stat.nlink < 1 ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
configurationError(`${label} identity is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function managerExecutables(
|
||||
manager: LocalServiceBridgeManager,
|
||||
): readonly string[] {
|
||||
return manager.kind === 'systemd'
|
||||
? Object.freeze([manager.executable])
|
||||
: Object.freeze([manager.serviceExecutable, manager.updateExecutable]);
|
||||
}
|
||||
|
||||
function defaultRunManager(
|
||||
request: Readonly<LocalServiceManagerRunRequest>,
|
||||
): Readonly<LocalServiceManagerRunResult> {
|
||||
const result = spawnSync(request.executable, [...request.args], {
|
||||
encoding: 'utf8',
|
||||
env: Object.freeze({
|
||||
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
LANG: 'C',
|
||||
LC_ALL: 'C',
|
||||
}),
|
||||
timeout: request.timeoutMs,
|
||||
maxBuffer: MAX_MANAGER_OUTPUT_BYTES,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
responseLost: result.error !== undefined || result.status === null,
|
||||
});
|
||||
}
|
||||
|
||||
function run(
|
||||
runner: NonNullable<
|
||||
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
|
||||
>,
|
||||
executable: string,
|
||||
args: readonly string[],
|
||||
): Readonly<LocalServiceManagerRunResult> {
|
||||
const result = runner({
|
||||
executable,
|
||||
args: Object.freeze([...args]),
|
||||
timeoutMs: MANAGER_TIMEOUT_MS,
|
||||
});
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== 'object' ||
|
||||
(result.status !== null && !Number.isSafeInteger(result.status)) ||
|
||||
(result.signal !== null && typeof result.signal !== 'string') ||
|
||||
typeof result.stdout !== 'string' ||
|
||||
typeof result.stderr !== 'string' ||
|
||||
typeof result.responseLost !== 'boolean' ||
|
||||
Buffer.byteLength(result.stdout, 'utf8') > MAX_MANAGER_OUTPUT_BYTES ||
|
||||
Buffer.byteLength(result.stderr, 'utf8') > MAX_MANAGER_OUTPUT_BYTES
|
||||
) {
|
||||
configurationError('legacy rollback manager runner returned invalid data');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function systemdObservation(
|
||||
manager: Extract<LocalServiceBridgeManager, { kind: 'systemd' }>,
|
||||
serviceName: 'qinglong' | 'qinglong3',
|
||||
fragmentPath: string,
|
||||
runner: NonNullable<
|
||||
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
|
||||
>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerRollbackObservation> {
|
||||
const result = run(runner, manager.executable, [
|
||||
'show',
|
||||
`${serviceName}.service`,
|
||||
'--no-page',
|
||||
'--property=LoadState,ActiveState,SubState,FragmentPath,MainPID,UnitFileState',
|
||||
]);
|
||||
const fields = new Map<string, string>();
|
||||
if (!result.responseLost && result.status === 0) {
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator > 0) {
|
||||
fields.set(line.slice(0, separator), line.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
const rawPid = Number(fields.get('MainPID') ?? '0');
|
||||
const loadState = fields.get('LoadState');
|
||||
const activeState = fields.get('ActiveState');
|
||||
const enabledState = fields.get('UnitFileState');
|
||||
const payload = Object.freeze({
|
||||
managerKind: 'systemd' as const,
|
||||
serviceName,
|
||||
fragmentPath: fields.get('FragmentPath') || fragmentPath,
|
||||
loadState:
|
||||
loadState === 'loaded'
|
||||
? ('loaded' as const)
|
||||
: loadState === 'not-found'
|
||||
? ('not-found' as const)
|
||||
: ('unknown' as const),
|
||||
activeState:
|
||||
activeState === 'active'
|
||||
? ('active' as const)
|
||||
: activeState === 'inactive'
|
||||
? ('inactive' as const)
|
||||
: activeState === 'failed'
|
||||
? ('failed' as const)
|
||||
: ('unknown' as const),
|
||||
subState: (fields.get('SubState') ?? 'unknown').slice(0, 128),
|
||||
enabledState:
|
||||
enabledState === 'enabled'
|
||||
? ('enabled' as const)
|
||||
: enabledState === 'disabled'
|
||||
? ('disabled' as const)
|
||||
: enabledState === 'static'
|
||||
? ('static' as const)
|
||||
: ('unknown' as const),
|
||||
mainPid:
|
||||
Number.isSafeInteger(rawPid) && rawPid >= 0 && rawPid <= 0x7fffffff
|
||||
? rawPid
|
||||
: 0,
|
||||
observedAtMs: now(),
|
||||
});
|
||||
return normalizeLocalServiceManagerRollbackObservation({
|
||||
...payload,
|
||||
observationDigest: localServiceManagerRollbackObservationDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function openrcObservation(
|
||||
manager: Extract<LocalServiceBridgeManager, { kind: 'openrc' }>,
|
||||
serviceName: 'qinglong' | 'qinglong3',
|
||||
fragmentPath: string,
|
||||
runner: NonNullable<
|
||||
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
|
||||
>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerRollbackObservation> {
|
||||
const service = run(runner, manager.serviceExecutable, [
|
||||
serviceName,
|
||||
'status',
|
||||
]);
|
||||
const enabled = run(runner, manager.updateExecutable, ['show', 'default']);
|
||||
const active = !service.responseLost && service.status === 0;
|
||||
const stopped =
|
||||
!service.responseLost &&
|
||||
service.status !== null &&
|
||||
(service.stdout.includes('stopped') || service.status === 3);
|
||||
const payload = Object.freeze({
|
||||
managerKind: 'openrc' as const,
|
||||
serviceName,
|
||||
fragmentPath,
|
||||
loadState: fs.existsSync(fragmentPath)
|
||||
? ('loaded' as const)
|
||||
: ('not-found' as const),
|
||||
activeState: active
|
||||
? ('active' as const)
|
||||
: stopped
|
||||
? ('inactive' as const)
|
||||
: ('unknown' as const),
|
||||
subState: active ? 'started' : stopped ? 'stopped' : 'unknown',
|
||||
enabledState:
|
||||
!enabled.responseLost &&
|
||||
enabled.status === 0 &&
|
||||
enabled.stdout
|
||||
.split('\n')
|
||||
.some((line) => new RegExp(`\\b${serviceName}\\b`).test(line))
|
||||
? ('enabled' as const)
|
||||
: ('unknown' as const),
|
||||
mainPid: 0,
|
||||
observedAtMs: now(),
|
||||
});
|
||||
return normalizeLocalServiceManagerRollbackObservation({
|
||||
...payload,
|
||||
observationDigest: localServiceManagerRollbackObservationDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function inspectManager(
|
||||
manager: LocalServiceBridgeManager,
|
||||
serviceName: 'qinglong' | 'qinglong3',
|
||||
fragmentPath: string,
|
||||
runner: NonNullable<
|
||||
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
|
||||
>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerRollbackObservation> {
|
||||
return manager.kind === 'systemd'
|
||||
? systemdObservation(manager, serviceName, fragmentPath, runner, now)
|
||||
: openrcObservation(manager, serviceName, fragmentPath, runner, now);
|
||||
}
|
||||
|
||||
function descriptorDigest(
|
||||
filePath: string,
|
||||
kind: LocalServiceManagerKind,
|
||||
): string {
|
||||
const bytes = readServiceBridgeFile(
|
||||
filePath,
|
||||
{
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
mode: kind === 'systemd' ? 0o644 : 0o755,
|
||||
maximumBytes: 64 * 1024,
|
||||
},
|
||||
'installed rollback service descriptor',
|
||||
);
|
||||
try {
|
||||
return sha256(bytes);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function ownerMaterialMatches(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackBridgeCommand>,
|
||||
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
|
||||
ownerUid: number,
|
||||
ownerGid: number,
|
||||
): boolean {
|
||||
const material = [
|
||||
[
|
||||
path.join(command.options.deploymentRoot, 'local-application.json'),
|
||||
authorization.applicationConfigDigest,
|
||||
'application configuration',
|
||||
],
|
||||
[
|
||||
path.join(
|
||||
command.options.deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
authorization.cutoverId,
|
||||
'0002-legacy-stopped.json',
|
||||
),
|
||||
authorization.commitmentFileDigest,
|
||||
'legacy silence commitment',
|
||||
],
|
||||
] as const;
|
||||
try {
|
||||
for (const [filePath, expectedDigest, label] of material) {
|
||||
const bytes = readServiceBridgeFile(
|
||||
filePath,
|
||||
{ uid: ownerUid, gid: ownerGid, mode: 0o600 },
|
||||
label,
|
||||
);
|
||||
try {
|
||||
if (sha256(bytes) !== expectedDigest) return false;
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function barrierRecord(
|
||||
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
|
||||
ownerUid: number,
|
||||
ownerGid: number,
|
||||
legacyDescriptorDigest: string,
|
||||
targetDescriptorDigest: string,
|
||||
legacyPreObservation: Readonly<LocalServiceManagerRollbackObservation>,
|
||||
targetPreObservation: Readonly<LocalServiceManagerRollbackObservation>,
|
||||
now: () => number,
|
||||
): Readonly<LegacyStartBarrier> {
|
||||
const payload = Object.freeze({
|
||||
schema: BARRIER_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
cutoverId: authorization.cutoverId,
|
||||
generation: authorization.generation,
|
||||
managerKind: authorization.managerKind,
|
||||
preparationDigest: authorization.preparationDigest,
|
||||
authorizationDigest: authorization.authorizationDigest,
|
||||
ownerUid,
|
||||
ownerGid,
|
||||
legacyDescriptorDigest,
|
||||
targetDescriptorDigest,
|
||||
legacyPreObservation,
|
||||
targetPreObservation,
|
||||
createdAtMs: now(),
|
||||
});
|
||||
return Object.freeze({ ...payload, barrierDigest: digest(payload) });
|
||||
}
|
||||
|
||||
function normalizeBarrier(
|
||||
value: unknown,
|
||||
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
|
||||
): Readonly<LegacyStartBarrier> {
|
||||
const barrier = object(value, 'legacy start barrier');
|
||||
exact(
|
||||
barrier,
|
||||
[
|
||||
'authorizationDigest',
|
||||
'barrierDigest',
|
||||
'createdAtMs',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'legacyDescriptorDigest',
|
||||
'legacyPreObservation',
|
||||
'managerKind',
|
||||
'ownerGid',
|
||||
'ownerUid',
|
||||
'preparationDigest',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'targetDescriptorDigest',
|
||||
'targetPreObservation',
|
||||
],
|
||||
'legacy start barrier',
|
||||
);
|
||||
const legacyPreObservation = normalizeLocalServiceManagerRollbackObservation(
|
||||
barrier.legacyPreObservation,
|
||||
);
|
||||
const targetPreObservation = normalizeLocalServiceManagerRollbackObservation(
|
||||
barrier.targetPreObservation,
|
||||
);
|
||||
const { barrierDigest, ...rawPayload } = barrier;
|
||||
const payload = {
|
||||
...rawPayload,
|
||||
legacyPreObservation,
|
||||
targetPreObservation,
|
||||
};
|
||||
if (
|
||||
barrier.schema !== BARRIER_SCHEMA ||
|
||||
barrier.schemaVersion !== 1 ||
|
||||
barrier.cutoverId !== authorization.cutoverId ||
|
||||
barrier.generation !== authorization.generation ||
|
||||
barrier.managerKind !== authorization.managerKind ||
|
||||
barrier.preparationDigest !== authorization.preparationDigest ||
|
||||
barrier.authorizationDigest !== authorization.authorizationDigest ||
|
||||
!Number.isSafeInteger(barrier.ownerUid) ||
|
||||
(barrier.ownerUid as number) < 0 ||
|
||||
!Number.isSafeInteger(barrier.ownerGid) ||
|
||||
(barrier.ownerGid as number) < 0 ||
|
||||
typeof barrier.legacyDescriptorDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(barrier.legacyDescriptorDigest) ||
|
||||
barrier.targetDescriptorDigest !== authorization.targetDescriptorDigest ||
|
||||
legacyPreObservation.managerKind !== authorization.managerKind ||
|
||||
legacyPreObservation.serviceName !== 'qinglong' ||
|
||||
targetPreObservation.managerKind !== authorization.managerKind ||
|
||||
targetPreObservation.serviceName !== 'qinglong3' ||
|
||||
!Number.isSafeInteger(barrier.createdAtMs) ||
|
||||
(barrier.createdAtMs as number) < authorization.requestedAtMs ||
|
||||
typeof barrierDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(barrierDigest) ||
|
||||
digest(payload) !== barrierDigest
|
||||
) {
|
||||
configurationError('legacy start barrier drifted');
|
||||
}
|
||||
return barrier as unknown as Readonly<LegacyStartBarrier>;
|
||||
}
|
||||
|
||||
function preconditionProved(
|
||||
observation: Readonly<LocalServiceManagerRollbackObservation>,
|
||||
expectedPath: string,
|
||||
): boolean {
|
||||
return (
|
||||
observation.fragmentPath === expectedPath &&
|
||||
observation.loadState === 'loaded' &&
|
||||
observation.activeState === 'inactive' &&
|
||||
observation.mainPid === 0
|
||||
);
|
||||
}
|
||||
|
||||
function finalStateProved(
|
||||
managerKind: LocalServiceManagerKind,
|
||||
legacy: Readonly<LocalServiceManagerRollbackObservation>,
|
||||
target: Readonly<LocalServiceManagerRollbackObservation>,
|
||||
legacyPath: string,
|
||||
targetPath: string,
|
||||
): boolean {
|
||||
return (
|
||||
legacy.fragmentPath === legacyPath &&
|
||||
legacy.loadState === 'loaded' &&
|
||||
legacy.activeState === 'active' &&
|
||||
(managerKind === 'openrc' || legacy.mainPid > 0) &&
|
||||
target.fragmentPath === targetPath &&
|
||||
target.loadState === 'loaded' &&
|
||||
target.activeState === 'inactive' &&
|
||||
target.mainPid === 0
|
||||
);
|
||||
}
|
||||
|
||||
function executeStart(
|
||||
manager: LocalServiceBridgeManager,
|
||||
runner: NonNullable<
|
||||
LocalServiceManagerLegacyRollbackBridgeDependencies['runManager']
|
||||
>,
|
||||
): Readonly<{ failed: boolean; responseLost: boolean }> {
|
||||
const result =
|
||||
manager.kind === 'systemd'
|
||||
? run(runner, manager.executable, ['start', 'qinglong.service'])
|
||||
: run(runner, manager.serviceExecutable, ['qinglong', 'start']);
|
||||
return Object.freeze({
|
||||
failed: !result.responseLost && result.status !== 0,
|
||||
responseLost: result.responseLost,
|
||||
});
|
||||
}
|
||||
|
||||
function outcomeRecord(
|
||||
authorization: Readonly<LocalServiceManagerLegacyStartAuthorization>,
|
||||
barrier: Readonly<LegacyStartBarrier>,
|
||||
state: LocalServiceManagerLegacyStartOutcome['state'],
|
||||
disposition: LocalServiceManagerRollbackMutationDisposition,
|
||||
manualReason: LocalServiceManagerRollbackManualReason | null,
|
||||
legacyObservation: Readonly<LocalServiceManagerRollbackObservation>,
|
||||
targetObservation: Readonly<LocalServiceManagerRollbackObservation>,
|
||||
now: () => number,
|
||||
): Readonly<LocalServiceManagerLegacyStartOutcome> {
|
||||
const payload = Object.freeze({
|
||||
schema: 'qinglong3-local-service-manager-legacy-start-outcome' as const,
|
||||
schemaVersion: 1 as const,
|
||||
state,
|
||||
cutoverId: authorization.cutoverId,
|
||||
profile: authorization.profile,
|
||||
instanceId: authorization.instanceId,
|
||||
generation: authorization.generation,
|
||||
activationDigest: authorization.activationDigest,
|
||||
managerKind: authorization.managerKind,
|
||||
preparationDigest: authorization.preparationDigest,
|
||||
authorizationDigest: authorization.authorizationDigest,
|
||||
barrierDigest: barrier.barrierDigest,
|
||||
legacyDescriptorDigest: barrier.legacyDescriptorDigest,
|
||||
targetDescriptorDigest: barrier.targetDescriptorDigest,
|
||||
mutationDisposition: disposition,
|
||||
manualReason,
|
||||
legacyObservation,
|
||||
targetObservation,
|
||||
completedAtMs: Math.max(
|
||||
now(),
|
||||
legacyObservation.observedAtMs,
|
||||
targetObservation.observedAtMs,
|
||||
),
|
||||
});
|
||||
return normalizeLocalServiceManagerLegacyStartOutcome({
|
||||
...payload,
|
||||
outcomeDigest: localServiceManagerLegacyStartOutcomeDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function readRootJson(filePath: string, label: string): unknown {
|
||||
const file = readOwnerPrivateJsonFile(filePath, label);
|
||||
if (file.uid !== 0 || file.gid !== 0) {
|
||||
configurationError(`${label} is not root-owned`);
|
||||
}
|
||||
return file.value;
|
||||
}
|
||||
|
||||
function publishOutcome(
|
||||
actionDirectory: string,
|
||||
ownerOutcomePath: string,
|
||||
ownerUid: number,
|
||||
ownerGid: number,
|
||||
outcome: Readonly<LocalServiceManagerLegacyStartOutcome>,
|
||||
): 'prepared' | 'existing' {
|
||||
const contents = `${JSON.stringify(outcome, null, 2)}\n`;
|
||||
const status = publishServiceBridgeFile(
|
||||
path.join(actionDirectory, 'outcome.json'),
|
||||
contents,
|
||||
0o600,
|
||||
0,
|
||||
0,
|
||||
'legacy start root outcome',
|
||||
);
|
||||
publishServiceBridgeFile(
|
||||
ownerOutcomePath,
|
||||
contents,
|
||||
0o600,
|
||||
ownerUid,
|
||||
ownerGid,
|
||||
'legacy start Owner outcome',
|
||||
);
|
||||
return status;
|
||||
}
|
||||
|
||||
function result(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackBridgeCommand>,
|
||||
status: 'prepared' | 'existing',
|
||||
outcome: Readonly<LocalServiceManagerLegacyStartOutcome>,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackBridgeResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status,
|
||||
state: outcome.state,
|
||||
cutoverId: outcome.cutoverId,
|
||||
generation: outcome.generation,
|
||||
outcomeDigest: outcome.outcomeDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function runLocalServiceManagerLegacyRollbackBridge(
|
||||
input: unknown,
|
||||
dependencies: Readonly<LocalServiceManagerLegacyRollbackBridgeDependencies> = {},
|
||||
): Readonly<LocalServiceManagerLegacyRollbackBridgeResult> {
|
||||
assertRootIdentity();
|
||||
const command =
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand(input);
|
||||
const runner = dependencies.runManager ?? defaultRunManager;
|
||||
const now = dependencies.now ?? Date.now;
|
||||
for (const executable of managerExecutables(command.options.manager)) {
|
||||
trustedRootExecutable(executable, 'service manager executable');
|
||||
}
|
||||
const authorizationPath = localServiceManagerLegacyStartAuthorizationPath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.cutoverId,
|
||||
command.request.generation,
|
||||
);
|
||||
const ownedAuthorization = readOwnerPrivateJsonFile(
|
||||
authorizationPath,
|
||||
'legacy start authorization',
|
||||
);
|
||||
const authorization = normalizeLocalServiceManagerLegacyStartAuthorization(
|
||||
ownedAuthorization.value,
|
||||
);
|
||||
if (
|
||||
authorization.authorizationDigest !==
|
||||
command.request.expectedAuthorizationDigest ||
|
||||
authorization.cutoverId !== command.request.cutoverId ||
|
||||
authorization.generation !== command.request.generation ||
|
||||
authorization.managerKind !== command.options.manager.kind
|
||||
) {
|
||||
configurationError('legacy rollback root authorization drifted');
|
||||
}
|
||||
const ownerUid = ownedAuthorization.uid;
|
||||
const ownerGid = ownedAuthorization.gid;
|
||||
validateServiceBridgeDirectory(
|
||||
command.options.deploymentRoot,
|
||||
ownerUid,
|
||||
ownerGid,
|
||||
0o700,
|
||||
'deploymentRoot',
|
||||
);
|
||||
validateServiceBridgeDirectory(
|
||||
path.join(command.options.deploymentRoot, 'service'),
|
||||
ownerUid,
|
||||
ownerGid,
|
||||
0o700,
|
||||
'serviceDescriptorRoot',
|
||||
);
|
||||
const legacyPath = localServiceManagerLegacyDescriptorPath(
|
||||
authorization.managerKind,
|
||||
);
|
||||
const targetPath = localServiceManagerTargetDescriptorPath(
|
||||
authorization.managerKind,
|
||||
);
|
||||
ensureRootServiceBridgeDirectory(
|
||||
command.options.controllerRoot,
|
||||
'serviceBridgeControllerRoot',
|
||||
);
|
||||
const rollbackRoot = path.join(
|
||||
command.options.controllerRoot,
|
||||
'legacy-rollbacks',
|
||||
);
|
||||
ensureRootServiceBridgeDirectory(rollbackRoot, 'legacyRollbackRoot');
|
||||
const actionDirectory = path.join(
|
||||
rollbackRoot,
|
||||
authorization.authorizationDigest,
|
||||
);
|
||||
ensureRootServiceBridgeDirectory(actionDirectory, 'legacyRollbackActionRoot');
|
||||
const barrierPath = path.join(actionDirectory, 'barrier.json');
|
||||
const rootOutcomePath = path.join(actionDirectory, 'outcome.json');
|
||||
const ownerOutcomePath = localServiceManagerLegacyStartOutcomePath(
|
||||
command.options.deploymentRoot,
|
||||
authorization.cutoverId,
|
||||
authorization.generation,
|
||||
);
|
||||
if (fs.existsSync(rootOutcomePath)) {
|
||||
const existing = normalizeLocalServiceManagerLegacyStartOutcome(
|
||||
readRootJson(rootOutcomePath, 'legacy start root outcome'),
|
||||
);
|
||||
if (
|
||||
existing.authorizationDigest !== authorization.authorizationDigest ||
|
||||
existing.preparationDigest !== authorization.preparationDigest ||
|
||||
existing.managerKind !== authorization.managerKind
|
||||
) {
|
||||
configurationError('legacy start root outcome drifted');
|
||||
}
|
||||
publishOutcome(
|
||||
actionDirectory,
|
||||
ownerOutcomePath,
|
||||
ownerUid,
|
||||
ownerGid,
|
||||
existing,
|
||||
);
|
||||
return result(command, 'existing', existing);
|
||||
}
|
||||
const legacyDigest = descriptorDigest(legacyPath, authorization.managerKind);
|
||||
const targetDigest = descriptorDigest(targetPath, authorization.managerKind);
|
||||
if (
|
||||
targetDigest !== authorization.targetDescriptorDigest ||
|
||||
!ownerMaterialMatches(command, authorization, ownerUid, ownerGid)
|
||||
) {
|
||||
configurationError('legacy rollback root preflight material drifted');
|
||||
}
|
||||
const replay = fs.existsSync(barrierPath);
|
||||
const barrier = replay
|
||||
? normalizeBarrier(
|
||||
readRootJson(barrierPath, 'legacy start barrier'),
|
||||
authorization,
|
||||
)
|
||||
: barrierRecord(
|
||||
authorization,
|
||||
ownerUid,
|
||||
ownerGid,
|
||||
legacyDigest,
|
||||
targetDigest,
|
||||
inspectManager(
|
||||
command.options.manager,
|
||||
'qinglong',
|
||||
legacyPath,
|
||||
runner,
|
||||
now,
|
||||
),
|
||||
inspectManager(
|
||||
command.options.manager,
|
||||
'qinglong3',
|
||||
targetPath,
|
||||
runner,
|
||||
now,
|
||||
),
|
||||
now,
|
||||
);
|
||||
if (!replay) {
|
||||
publishServiceBridgeFile(
|
||||
barrierPath,
|
||||
`${JSON.stringify(barrier, null, 2)}\n`,
|
||||
0o600,
|
||||
0,
|
||||
0,
|
||||
'legacy start barrier',
|
||||
);
|
||||
dependencies.afterBarrier?.();
|
||||
}
|
||||
let mutation: Readonly<{ failed: boolean; responseLost: boolean }> =
|
||||
Object.freeze({ failed: false, responseLost: false });
|
||||
let immediateReason: LocalServiceManagerRollbackManualReason | null = null;
|
||||
if (
|
||||
!preconditionProved(barrier.legacyPreObservation, legacyPath) ||
|
||||
!preconditionProved(barrier.targetPreObservation, targetPath)
|
||||
) {
|
||||
immediateReason = 'start_precondition_unproved';
|
||||
} else if (
|
||||
descriptorDigest(legacyPath, authorization.managerKind) !==
|
||||
barrier.legacyDescriptorDigest ||
|
||||
descriptorDigest(targetPath, authorization.managerKind) !==
|
||||
barrier.targetDescriptorDigest
|
||||
) {
|
||||
immediateReason = 'service_descriptor_drifted';
|
||||
} else if (
|
||||
!ownerMaterialMatches(command, authorization, ownerUid, ownerGid)
|
||||
) {
|
||||
immediateReason = 'authorization_material_drifted';
|
||||
} else if (!replay) {
|
||||
mutation = executeStart(command.options.manager, runner);
|
||||
dependencies.afterStart?.();
|
||||
}
|
||||
const legacyObservation = inspectManager(
|
||||
command.options.manager,
|
||||
'qinglong',
|
||||
legacyPath,
|
||||
runner,
|
||||
now,
|
||||
);
|
||||
const targetObservation = inspectManager(
|
||||
command.options.manager,
|
||||
'qinglong3',
|
||||
targetPath,
|
||||
runner,
|
||||
now,
|
||||
);
|
||||
let descriptorDrifted = false;
|
||||
try {
|
||||
descriptorDrifted =
|
||||
descriptorDigest(legacyPath, authorization.managerKind) !==
|
||||
barrier.legacyDescriptorDigest ||
|
||||
descriptorDigest(targetPath, authorization.managerKind) !==
|
||||
barrier.targetDescriptorDigest;
|
||||
} catch {
|
||||
descriptorDrifted = true;
|
||||
}
|
||||
const proved =
|
||||
immediateReason === null &&
|
||||
!descriptorDrifted &&
|
||||
finalStateProved(
|
||||
authorization.managerKind,
|
||||
legacyObservation,
|
||||
targetObservation,
|
||||
legacyPath,
|
||||
targetPath,
|
||||
);
|
||||
const disposition: LocalServiceManagerRollbackMutationDisposition = replay
|
||||
? 'replay-inspected'
|
||||
: mutation.responseLost
|
||||
? 'response-loss-inspected'
|
||||
: 'executed';
|
||||
const manualReason: LocalServiceManagerRollbackManualReason | null = proved
|
||||
? null
|
||||
: immediateReason ??
|
||||
(descriptorDrifted
|
||||
? 'service_descriptor_drifted'
|
||||
: mutation.failed
|
||||
? 'manager_command_failed'
|
||||
: 'manager_state_unproved');
|
||||
const outcome = outcomeRecord(
|
||||
authorization,
|
||||
barrier,
|
||||
proved ? 'legacy_running' : 'manual_required',
|
||||
disposition,
|
||||
manualReason,
|
||||
legacyObservation,
|
||||
targetObservation,
|
||||
now,
|
||||
);
|
||||
const status = publishOutcome(
|
||||
actionDirectory,
|
||||
ownerOutcomePath,
|
||||
ownerUid,
|
||||
ownerGid,
|
||||
outcome,
|
||||
);
|
||||
return result(command, status, outcome);
|
||||
}
|
||||
|
||||
export function runLocalServiceManagerLegacyRollbackBridgeCommandFile(
|
||||
commandFile: string,
|
||||
dependencies: Readonly<LocalServiceManagerLegacyRollbackBridgeDependencies> = {},
|
||||
): Readonly<LocalServiceManagerLegacyRollbackBridgeResult> {
|
||||
return runLocalServiceManagerLegacyRollbackBridge(
|
||||
readPrivateLocalCommandFile(commandFile),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
+814
@@ -0,0 +1,814 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
readPrivateLocalJsonFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
} from '../../cutover/instanceLineage';
|
||||
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||
import { currentIdentity } from '../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
} from '../../foundation/files';
|
||||
import {
|
||||
localServiceManagerLegacyCompletionPath,
|
||||
localServiceManagerLegacyStartAuthorizationDigest,
|
||||
localServiceManagerLegacyStartAuthorizationPath,
|
||||
localServiceManagerLegacyStartOutcomePath,
|
||||
normalizeLocalServiceManagerLegacyStartAuthorization,
|
||||
normalizeLocalServiceManagerLegacyStartOutcome,
|
||||
type LocalServiceManagerLegacyStartAuthorization,
|
||||
type LocalServiceManagerLegacyStartOutcome,
|
||||
} from './contract';
|
||||
import {
|
||||
localServiceManagerLegacyRollbackPreparationPath,
|
||||
normalizeLocalServiceManagerLegacyRollbackPreparation,
|
||||
prepareLocalServiceManagerLegacyRollback,
|
||||
type LocalServiceManagerLegacyRollbackPreparation,
|
||||
} from './preparation';
|
||||
|
||||
const COMPLETION_SCHEMA =
|
||||
'qinglong3-local-service-manager-legacy-rollback-completion';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
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;
|
||||
|
||||
interface RollbackIdentity {
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly generation: number;
|
||||
readonly expectedActivationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackAuthorizeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.legacy-rollback.authorize';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<
|
||||
RollbackIdentity & {
|
||||
expectedPreparationDigest: string;
|
||||
expectedInstanceHeadDigest: string;
|
||||
requestedAtMs: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackAuthorizeResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.legacy-rollback.authorize';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'legacy_restart_requested';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly preparationDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackConsumeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.legacy-rollback.consume';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
allowRootService: boolean;
|
||||
}>;
|
||||
readonly request: Readonly<
|
||||
RollbackIdentity & {
|
||||
expectedPreparationDigest: string;
|
||||
expectedAuthorizationDigest: string;
|
||||
expectedAuthorizationHeadDigest: string;
|
||||
requestedAtMs: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackConsumeResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.legacy-rollback.consume';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'legacy_running' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly outcomeDigest: string;
|
||||
readonly completionDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackCompletion {
|
||||
readonly schema: typeof COMPLETION_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'legacy_running' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly generation: number;
|
||||
readonly activationDigest: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly expectedAuthorizationHeadDigest: string;
|
||||
readonly rootOutcomeDigest: string;
|
||||
readonly rootBarrierDigest: string;
|
||||
readonly legacyDescriptorDigest: string;
|
||||
readonly targetDescriptorDigest: string;
|
||||
readonly legacyObservationDigest: string;
|
||||
readonly targetObservationDigest: string;
|
||||
readonly manualReason: LocalServiceManagerLegacyStartOutcome['manualReason'];
|
||||
readonly completedAtMs: number;
|
||||
readonly completionDigest: 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 normalizeIdentity(
|
||||
value: Record<string, unknown>,
|
||||
): Readonly<RollbackIdentity> {
|
||||
if (
|
||||
typeof value.cutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(value.cutoverId) ||
|
||||
(value.profile !== 'edge' && value.profile !== 'standalone') ||
|
||||
typeof value.instanceId !== 'string' ||
|
||||
!INSTANCE_ID_PATTERN.test(value.instanceId) ||
|
||||
!Number.isSafeInteger(value.generation) ||
|
||||
(value.generation as number) < 1 ||
|
||||
(value.generation as number) > MAX_GENERATION ||
|
||||
typeof value.expectedActivationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.expectedActivationDigest)
|
||||
) {
|
||||
configurationError('legacy rollback identity is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
cutoverId: value.cutoverId,
|
||||
profile: value.profile,
|
||||
instanceId: value.instanceId,
|
||||
generation: value.generation as number,
|
||||
expectedActivationDigest: value.expectedActivationDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAuthorizeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeCommand> {
|
||||
const command = object(value, 'legacy rollback authorize 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',
|
||||
'expectedPreparationDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const identity = currentIdentity();
|
||||
const rollbackIdentity = normalizeIdentity(request);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !==
|
||||
'local.deployment.service-manager.legacy-rollback.authorize' ||
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService ||
|
||||
typeof request.expectedPreparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedPreparationDigest) ||
|
||||
typeof request.expectedInstanceHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedInstanceHeadDigest) ||
|
||||
!Number.isSafeInteger(request.requestedAtMs) ||
|
||||
(request.requestedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('legacy rollback authorize command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation:
|
||||
'local.deployment.service-manager.legacy-rollback.authorize' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
...rollbackIdentity,
|
||||
expectedPreparationDigest: request.expectedPreparationDigest,
|
||||
expectedInstanceHeadDigest: request.expectedInstanceHeadDigest,
|
||||
requestedAtMs: request.requestedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeConsumeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackConsumeCommand> {
|
||||
const command = object(value, 'legacy rollback consume 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',
|
||||
'expectedAuthorizationDigest',
|
||||
'expectedAuthorizationHeadDigest',
|
||||
'expectedPreparationDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'profile',
|
||||
'requestedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const identity = currentIdentity();
|
||||
const rollbackIdentity = normalizeIdentity(request);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !==
|
||||
'local.deployment.service-manager.legacy-rollback.consume' ||
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService ||
|
||||
typeof request.expectedPreparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedPreparationDigest) ||
|
||||
typeof request.expectedAuthorizationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedAuthorizationDigest) ||
|
||||
typeof request.expectedAuthorizationHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedAuthorizationHeadDigest) ||
|
||||
!Number.isSafeInteger(request.requestedAtMs) ||
|
||||
(request.requestedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('legacy rollback consume command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation:
|
||||
'local.deployment.service-manager.legacy-rollback.consume' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
allowRootService: options.allowRootService,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
...rollbackIdentity,
|
||||
expectedPreparationDigest: request.expectedPreparationDigest,
|
||||
expectedAuthorizationDigest: request.expectedAuthorizationDigest,
|
||||
expectedAuthorizationHeadDigest: request.expectedAuthorizationHeadDigest,
|
||||
requestedAtMs: request.requestedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function readPreparation(
|
||||
deploymentRoot: string,
|
||||
identity: Readonly<RollbackIdentity>,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
|
||||
return normalizeLocalServiceManagerLegacyRollbackPreparation(
|
||||
readPrivateLocalJsonFile(
|
||||
localServiceManagerLegacyRollbackPreparationPath(
|
||||
deploymentRoot,
|
||||
identity.cutoverId,
|
||||
identity.generation,
|
||||
),
|
||||
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function assertPreparationBinding(
|
||||
preparation: Readonly<LocalServiceManagerLegacyRollbackPreparation>,
|
||||
identity: Readonly<RollbackIdentity>,
|
||||
expectedPreparationDigest: string,
|
||||
): void {
|
||||
if (
|
||||
preparation.cutoverId !== identity.cutoverId ||
|
||||
preparation.profile !== identity.profile ||
|
||||
preparation.instanceId !== identity.instanceId ||
|
||||
preparation.generation !== identity.generation ||
|
||||
preparation.activationDigest !== identity.expectedActivationDigest ||
|
||||
preparation.preparationDigest !== expectedPreparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback preparation binding drifted');
|
||||
}
|
||||
}
|
||||
|
||||
function authorizationRecord(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackAuthorizeCommand>,
|
||||
preparation: Readonly<LocalServiceManagerLegacyRollbackPreparation>,
|
||||
): Readonly<LocalServiceManagerLegacyStartAuthorization> {
|
||||
const payload = Object.freeze({
|
||||
schema:
|
||||
'qinglong3-local-service-manager-legacy-start-authorization' as const,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'legacy_restart_requested' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
generation: command.request.generation,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
managerKind: preparation.managerKind,
|
||||
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
|
||||
preparationDigest: preparation.preparationDigest,
|
||||
reconciliationEvidenceDigest: preparation.reconciliation.evidenceDigest,
|
||||
applicationConfigDigest: preparation.applicationConfigDigest,
|
||||
commitmentFileDigest: preparation.commitmentFileDigest,
|
||||
targetDescriptorDigest: preparation.targetDescriptorDigest,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
});
|
||||
return normalizeLocalServiceManagerLegacyStartAuthorization({
|
||||
...payload,
|
||||
authorizationDigest:
|
||||
localServiceManagerLegacyStartAuthorizationDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function authorizeResult(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackAuthorizeCommand>,
|
||||
status: 'prepared' | 'existing',
|
||||
preparationDigest: string,
|
||||
authorizationDigest: string,
|
||||
instanceHeadDigest: string,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status,
|
||||
state: 'legacy_restart_requested' as const,
|
||||
cutoverId: command.request.cutoverId,
|
||||
generation: command.request.generation,
|
||||
preparationDigest,
|
||||
authorizationDigest,
|
||||
instanceHeadDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function authorizeLocalServiceManagerLegacyRollback(
|
||||
input: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeResult> {
|
||||
const command = normalizeAuthorizeCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const preparation = readPreparation(
|
||||
command.options.deploymentRoot,
|
||||
command.request,
|
||||
);
|
||||
assertPreparationBinding(
|
||||
preparation,
|
||||
command.request,
|
||||
command.request.expectedPreparationDigest,
|
||||
);
|
||||
const authorization = authorizationRecord(command, preparation);
|
||||
const authorizationPath = localServiceManagerLegacyStartAuthorizationPath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.cutoverId,
|
||||
command.request.generation,
|
||||
);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (head.state === 'legacy_restart_requested') {
|
||||
const existing = normalizeLocalServiceManagerLegacyStartAuthorization(
|
||||
readPrivateLocalJsonFile(authorizationPath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
existing.authorizationDigest !== authorization.authorizationDigest ||
|
||||
head.sourceRecordDigest !== existing.authorizationDigest ||
|
||||
head.previousHeadDigest !== command.request.expectedInstanceHeadDigest
|
||||
) {
|
||||
configurationError('legacy rollback authorization replay drifted');
|
||||
}
|
||||
return authorizeResult(
|
||||
command,
|
||||
'existing',
|
||||
preparation.preparationDigest,
|
||||
existing.authorizationDigest,
|
||||
head.headDigest,
|
||||
);
|
||||
}
|
||||
if (
|
||||
head.state !== 'rollback_prepared' ||
|
||||
head.headDigest !== command.request.expectedInstanceHeadDigest ||
|
||||
head.sourceRecordDigest !== preparation.preparationDigest ||
|
||||
head.profile !== command.request.profile ||
|
||||
head.cutoverId !== command.request.cutoverId ||
|
||||
head.activationDigest !== command.request.expectedActivationDigest ||
|
||||
head.generation !== command.request.generation ||
|
||||
preparation.requestedAtMs > command.request.requestedAtMs
|
||||
) {
|
||||
configurationError('legacy rollback authorization lost the instance head');
|
||||
}
|
||||
const revalidated = prepareLocalServiceManagerLegacyRollback({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.prepare',
|
||||
options: command.options,
|
||||
request: {
|
||||
cutoverId: preparation.cutoverId,
|
||||
profile: preparation.profile,
|
||||
instanceId: preparation.instanceId,
|
||||
generation: preparation.generation,
|
||||
expectedActivationDigest: preparation.activationDigest,
|
||||
expectedStoppedRecordDigest: preparation.stoppedRecordDigest,
|
||||
expectedInstanceHeadDigest: preparation.expectedInstanceHeadDigest,
|
||||
requestedAtMs: preparation.requestedAtMs,
|
||||
},
|
||||
});
|
||||
if (
|
||||
revalidated.status !== 'existing' ||
|
||||
revalidated.state !== 'rollback_prepared' ||
|
||||
revalidated.preparationDigest !== preparation.preparationDigest
|
||||
) {
|
||||
configurationError('legacy rollback preparation cannot authorize start');
|
||||
}
|
||||
const contents = `${JSON.stringify(authorization, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
authorizationPath,
|
||||
contents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy start authorization',
|
||||
);
|
||||
const status = publishExactFile(
|
||||
authorizationPath,
|
||||
contents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy start authorization',
|
||||
);
|
||||
const next = advanceLocalCutoverInstanceHead(
|
||||
{
|
||||
options: { deploymentRoot: command.options.deploymentRoot },
|
||||
request: command.request,
|
||||
},
|
||||
identity.uid,
|
||||
'legacy_restart_requested',
|
||||
command.request.generation,
|
||||
authorization.authorizationDigest,
|
||||
);
|
||||
return authorizeResult(
|
||||
command,
|
||||
status,
|
||||
preparation.preparationDigest,
|
||||
authorization.authorizationDigest,
|
||||
next.headDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function completionRecord(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackConsumeCommand>,
|
||||
outcome: Readonly<LocalServiceManagerLegacyStartOutcome>,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackCompletion> {
|
||||
const payload = Object.freeze({
|
||||
schema: COMPLETION_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: outcome.state,
|
||||
cutoverId: command.request.cutoverId,
|
||||
profile: command.request.profile,
|
||||
instanceId: command.request.instanceId,
|
||||
generation: command.request.generation,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
preparationDigest: command.request.expectedPreparationDigest,
|
||||
authorizationDigest: command.request.expectedAuthorizationDigest,
|
||||
expectedAuthorizationHeadDigest:
|
||||
command.request.expectedAuthorizationHeadDigest,
|
||||
rootOutcomeDigest: outcome.outcomeDigest,
|
||||
rootBarrierDigest: outcome.barrierDigest,
|
||||
legacyDescriptorDigest: outcome.legacyDescriptorDigest,
|
||||
targetDescriptorDigest: outcome.targetDescriptorDigest,
|
||||
legacyObservationDigest: outcome.legacyObservation.observationDigest,
|
||||
targetObservationDigest: outcome.targetObservation.observationDigest,
|
||||
manualReason: outcome.manualReason,
|
||||
completedAtMs: Math.max(
|
||||
command.request.requestedAtMs,
|
||||
outcome.completedAtMs,
|
||||
),
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
completionDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerLegacyRollbackCompletion(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackCompletion> {
|
||||
const record = object(value, 'legacy rollback completion');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'activationDigest',
|
||||
'authorizationDigest',
|
||||
'completedAtMs',
|
||||
'completionDigest',
|
||||
'cutoverId',
|
||||
'expectedAuthorizationHeadDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacyDescriptorDigest',
|
||||
'legacyObservationDigest',
|
||||
'manualReason',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'rootBarrierDigest',
|
||||
'rootOutcomeDigest',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
'targetDescriptorDigest',
|
||||
'targetObservationDigest',
|
||||
],
|
||||
'legacy rollback completion',
|
||||
);
|
||||
const { completionDigest, ...payload } = record;
|
||||
const digests = [
|
||||
record.activationDigest,
|
||||
record.preparationDigest,
|
||||
record.authorizationDigest,
|
||||
record.expectedAuthorizationHeadDigest,
|
||||
record.rootOutcomeDigest,
|
||||
record.rootBarrierDigest,
|
||||
record.legacyDescriptorDigest,
|
||||
record.targetDescriptorDigest,
|
||||
record.legacyObservationDigest,
|
||||
record.targetObservationDigest,
|
||||
];
|
||||
if (
|
||||
record.schema !== COMPLETION_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
(record.state !== 'legacy_running' && record.state !== 'manual_required') ||
|
||||
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 ||
|
||||
digests.some(
|
||||
(candidate) =>
|
||||
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||
) ||
|
||||
(record.manualReason !== null &&
|
||||
record.manualReason !== 'start_precondition_unproved' &&
|
||||
record.manualReason !== 'authorization_material_drifted' &&
|
||||
record.manualReason !== 'manager_command_failed' &&
|
||||
record.manualReason !== 'manager_state_unproved' &&
|
||||
record.manualReason !== 'service_descriptor_drifted') ||
|
||||
(record.state === 'manual_required') !== (record.manualReason !== null) ||
|
||||
!Number.isSafeInteger(record.completedAtMs) ||
|
||||
(record.completedAtMs as number) < 0 ||
|
||||
typeof completionDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(completionDigest) ||
|
||||
cutoverDigest(payload) !== completionDigest
|
||||
) {
|
||||
configurationError('legacy rollback completion drifted');
|
||||
}
|
||||
return record as unknown as Readonly<LocalServiceManagerLegacyRollbackCompletion>;
|
||||
}
|
||||
|
||||
function consumeResult(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackConsumeCommand>,
|
||||
status: 'prepared' | 'existing',
|
||||
completion: Readonly<LocalServiceManagerLegacyRollbackCompletion>,
|
||||
instanceHeadDigest: string,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackConsumeResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status,
|
||||
state: completion.state,
|
||||
cutoverId: completion.cutoverId,
|
||||
generation: completion.generation,
|
||||
outcomeDigest: completion.rootOutcomeDigest,
|
||||
completionDigest: completion.completionDigest,
|
||||
instanceHeadDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function consumeLocalServiceManagerLegacyRollback(
|
||||
input: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackConsumeResult> {
|
||||
const command = normalizeConsumeCommand(input);
|
||||
const identity = currentIdentity();
|
||||
const preparation = readPreparation(
|
||||
command.options.deploymentRoot,
|
||||
command.request,
|
||||
);
|
||||
assertPreparationBinding(
|
||||
preparation,
|
||||
command.request,
|
||||
command.request.expectedPreparationDigest,
|
||||
);
|
||||
const authorization = normalizeLocalServiceManagerLegacyStartAuthorization(
|
||||
readPrivateLocalJsonFile(
|
||||
localServiceManagerLegacyStartAuthorizationPath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.cutoverId,
|
||||
command.request.generation,
|
||||
),
|
||||
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
|
||||
),
|
||||
);
|
||||
if (
|
||||
authorization.cutoverId !== command.request.cutoverId ||
|
||||
authorization.profile !== command.request.profile ||
|
||||
authorization.instanceId !== command.request.instanceId ||
|
||||
authorization.generation !== command.request.generation ||
|
||||
authorization.activationDigest !==
|
||||
command.request.expectedActivationDigest ||
|
||||
authorization.preparationDigest !== preparation.preparationDigest ||
|
||||
authorization.authorizationDigest !==
|
||||
command.request.expectedAuthorizationDigest
|
||||
) {
|
||||
configurationError('legacy start authorization binding drifted');
|
||||
}
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
command.request.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
const completionPath = localServiceManagerLegacyCompletionPath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.cutoverId,
|
||||
command.request.generation,
|
||||
);
|
||||
if (head.state === 'legacy_running' || head.state === 'manual_required') {
|
||||
const existing = normalizeLocalServiceManagerLegacyRollbackCompletion(
|
||||
readPrivateLocalJsonFile(completionPath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
existing.state !== head.state ||
|
||||
existing.authorizationDigest !== authorization.authorizationDigest ||
|
||||
existing.expectedAuthorizationHeadDigest !==
|
||||
command.request.expectedAuthorizationHeadDigest ||
|
||||
head.sourceRecordDigest !== existing.completionDigest ||
|
||||
head.previousHeadDigest !==
|
||||
command.request.expectedAuthorizationHeadDigest
|
||||
) {
|
||||
configurationError('legacy rollback completion replay drifted');
|
||||
}
|
||||
return consumeResult(command, 'existing', existing, head.headDigest);
|
||||
}
|
||||
if (
|
||||
head.state !== 'legacy_restart_requested' ||
|
||||
head.headDigest !== command.request.expectedAuthorizationHeadDigest ||
|
||||
head.sourceRecordDigest !== authorization.authorizationDigest ||
|
||||
head.profile !== command.request.profile ||
|
||||
head.cutoverId !== command.request.cutoverId ||
|
||||
head.activationDigest !== command.request.expectedActivationDigest ||
|
||||
head.generation !== command.request.generation ||
|
||||
authorization.requestedAtMs > command.request.requestedAtMs
|
||||
) {
|
||||
configurationError('legacy rollback consume lost the instance head');
|
||||
}
|
||||
const outcome = normalizeLocalServiceManagerLegacyStartOutcome(
|
||||
readPrivateLocalJsonFile(
|
||||
localServiceManagerLegacyStartOutcomePath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.cutoverId,
|
||||
command.request.generation,
|
||||
),
|
||||
{ maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES },
|
||||
),
|
||||
);
|
||||
if (
|
||||
outcome.cutoverId !== command.request.cutoverId ||
|
||||
outcome.profile !== command.request.profile ||
|
||||
outcome.instanceId !== command.request.instanceId ||
|
||||
outcome.generation !== command.request.generation ||
|
||||
outcome.activationDigest !== command.request.expectedActivationDigest ||
|
||||
outcome.managerKind !== preparation.managerKind ||
|
||||
outcome.preparationDigest !== preparation.preparationDigest ||
|
||||
outcome.authorizationDigest !== authorization.authorizationDigest ||
|
||||
outcome.targetDescriptorDigest !== preparation.targetDescriptorDigest ||
|
||||
outcome.completedAtMs > command.request.requestedAtMs
|
||||
) {
|
||||
configurationError('legacy start outcome binding drifted');
|
||||
}
|
||||
const completion = completionRecord(command, outcome);
|
||||
const contents = `${JSON.stringify(completion, null, 2)}\n`;
|
||||
preflightPublishedFile(
|
||||
completionPath,
|
||||
contents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy rollback completion',
|
||||
);
|
||||
const status = publishExactFile(
|
||||
completionPath,
|
||||
contents,
|
||||
0o600,
|
||||
identity.uid,
|
||||
'legacy rollback completion',
|
||||
);
|
||||
const next = advanceLocalCutoverInstanceHead(
|
||||
{
|
||||
options: { deploymentRoot: command.options.deploymentRoot },
|
||||
request: command.request,
|
||||
},
|
||||
identity.uid,
|
||||
completion.state,
|
||||
command.request.generation,
|
||||
completion.completionDigest,
|
||||
);
|
||||
return consumeResult(command, status, completion, next.headDigest);
|
||||
}
|
||||
|
||||
export function authorizeLocalServiceManagerLegacyRollbackCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackAuthorizeResult> {
|
||||
return authorizeLocalServiceManagerLegacyRollback(
|
||||
readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function consumeLocalServiceManagerLegacyRollbackCommandFile(
|
||||
filePath: string,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackConsumeResult> {
|
||||
return consumeLocalServiceManagerLegacyRollback(
|
||||
readPrivateLocalJsonFile(filePath, {
|
||||
maxBytes: MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import type {
|
||||
LocalServiceBridgeManager,
|
||||
LocalServiceManagerKind,
|
||||
} from '../serviceBridgeContract';
|
||||
|
||||
const AUTHORIZATION_SCHEMA =
|
||||
'qinglong3-local-service-manager-legacy-start-authorization';
|
||||
const OUTCOME_SCHEMA = 'qinglong3-local-service-manager-legacy-start-outcome';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
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;
|
||||
const MAX_PID = 0x7fffffff;
|
||||
|
||||
export type LocalServiceManagerRollbackMutationDisposition =
|
||||
| 'executed'
|
||||
| 'response-loss-inspected'
|
||||
| 'replay-inspected';
|
||||
|
||||
export type LocalServiceManagerRollbackManualReason =
|
||||
| 'start_precondition_unproved'
|
||||
| 'authorization_material_drifted'
|
||||
| 'manager_command_failed'
|
||||
| 'manager_state_unproved'
|
||||
| 'service_descriptor_drifted';
|
||||
|
||||
export interface LocalServiceManagerLegacyStartAuthorization {
|
||||
readonly schema: typeof AUTHORIZATION_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'legacy_restart_requested';
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly generation: number;
|
||||
readonly activationDigest: string;
|
||||
readonly managerKind: LocalServiceManagerKind;
|
||||
readonly expectedInstanceHeadDigest: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly reconciliationEvidenceDigest: string;
|
||||
readonly applicationConfigDigest: string;
|
||||
readonly commitmentFileDigest: string;
|
||||
readonly targetDescriptorDigest: string;
|
||||
readonly requestedAtMs: number;
|
||||
readonly authorizationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerRollbackObservation {
|
||||
readonly managerKind: LocalServiceManagerKind;
|
||||
readonly serviceName: 'qinglong' | 'qinglong3';
|
||||
readonly fragmentPath: string;
|
||||
readonly loadState: 'loaded' | 'not-found' | 'unknown';
|
||||
readonly activeState: 'active' | 'inactive' | 'failed' | 'unknown';
|
||||
readonly subState: string;
|
||||
readonly enabledState: 'enabled' | 'disabled' | 'static' | 'unknown';
|
||||
readonly mainPid: number;
|
||||
readonly observedAtMs: number;
|
||||
readonly observationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyStartOutcome {
|
||||
readonly schema: typeof OUTCOME_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'legacy_running' | 'manual_required';
|
||||
readonly cutoverId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly generation: number;
|
||||
readonly activationDigest: string;
|
||||
readonly managerKind: LocalServiceManagerKind;
|
||||
readonly preparationDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly barrierDigest: string;
|
||||
readonly legacyDescriptorDigest: string;
|
||||
readonly targetDescriptorDigest: string;
|
||||
readonly mutationDisposition: LocalServiceManagerRollbackMutationDisposition;
|
||||
readonly manualReason: LocalServiceManagerRollbackManualReason | null;
|
||||
readonly legacyObservation: Readonly<LocalServiceManagerRollbackObservation>;
|
||||
readonly targetObservation: Readonly<LocalServiceManagerRollbackObservation>;
|
||||
readonly completedAtMs: number;
|
||||
readonly outcomeDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackBridgeCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.legacy-rollback.execute';
|
||||
readonly options: Readonly<{
|
||||
deploymentRoot: string;
|
||||
controllerRoot: string;
|
||||
allowRootController: true;
|
||||
manager: LocalServiceBridgeManager;
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
cutoverId: string;
|
||||
generation: number;
|
||||
expectedAuthorizationDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalServiceManagerLegacyRollbackBridgeResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.service-manager.legacy-rollback.execute';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: LocalServiceManagerLegacyStartOutcome['state'];
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly outcomeDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
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 digest(value: unknown): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
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 normalizedManager(value: unknown): LocalServiceBridgeManager {
|
||||
const manager = object(value, 'manager');
|
||||
if (manager.kind === 'systemd') {
|
||||
exact(manager, ['executable', 'kind'], 'manager');
|
||||
return Object.freeze({
|
||||
kind: 'systemd' as const,
|
||||
executable: safeAbsolutePath(manager.executable, 'systemd executable'),
|
||||
});
|
||||
}
|
||||
exact(manager, ['kind', 'serviceExecutable', 'updateExecutable'], 'manager');
|
||||
if (manager.kind !== 'openrc') configurationError('manager kind is invalid');
|
||||
return Object.freeze({
|
||||
kind: 'openrc' as const,
|
||||
serviceExecutable: safeAbsolutePath(
|
||||
manager.serviceExecutable,
|
||||
'OpenRC service executable',
|
||||
),
|
||||
updateExecutable: safeAbsolutePath(
|
||||
manager.updateExecutable,
|
||||
'OpenRC update executable',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function localServiceManagerLegacyStartAuthorizationPath(
|
||||
deploymentRoot: string,
|
||||
cutoverId: string,
|
||||
generation: number,
|
||||
): string {
|
||||
return path.join(
|
||||
deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
cutoverId,
|
||||
`service-manager-g${String(generation).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}-legacy-start-intent.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function localServiceManagerLegacyStartOutcomePath(
|
||||
deploymentRoot: string,
|
||||
cutoverId: string,
|
||||
generation: number,
|
||||
): string {
|
||||
return path.join(
|
||||
deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
cutoverId,
|
||||
`service-manager-g${String(generation).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}-legacy-start-outcome.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function localServiceManagerLegacyCompletionPath(
|
||||
deploymentRoot: string,
|
||||
cutoverId: string,
|
||||
generation: number,
|
||||
): string {
|
||||
return path.join(
|
||||
deploymentRoot,
|
||||
'service',
|
||||
'cutovers',
|
||||
cutoverId,
|
||||
`service-manager-g${String(generation).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}-legacy-completion.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function localServiceManagerLegacyDescriptorPath(
|
||||
kind: LocalServiceManagerKind,
|
||||
): string {
|
||||
return kind === 'systemd'
|
||||
? '/etc/systemd/system/qinglong.service'
|
||||
: '/etc/init.d/qinglong';
|
||||
}
|
||||
|
||||
export function localServiceManagerTargetDescriptorPath(
|
||||
kind: LocalServiceManagerKind,
|
||||
): string {
|
||||
return kind === 'systemd'
|
||||
? '/etc/systemd/system/qinglong3.service'
|
||||
: '/etc/init.d/qinglong3';
|
||||
}
|
||||
|
||||
export function localServiceManagerLegacyStartAuthorizationDigest(
|
||||
value: Omit<
|
||||
LocalServiceManagerLegacyStartAuthorization,
|
||||
'authorizationDigest'
|
||||
>,
|
||||
): string {
|
||||
return digest(value);
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerLegacyStartAuthorization(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyStartAuthorization> {
|
||||
const record = object(value, 'legacy start authorization');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'activationDigest',
|
||||
'applicationConfigDigest',
|
||||
'authorizationDigest',
|
||||
'commitmentFileDigest',
|
||||
'cutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'managerKind',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'reconciliationEvidenceDigest',
|
||||
'requestedAtMs',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
'targetDescriptorDigest',
|
||||
],
|
||||
'legacy start authorization',
|
||||
);
|
||||
const { authorizationDigest, ...payload } = record;
|
||||
if (
|
||||
record.schema !== AUTHORIZATION_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.state !== 'legacy_restart_requested' ||
|
||||
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) ||
|
||||
(record.managerKind !== 'systemd' && record.managerKind !== 'openrc') ||
|
||||
typeof record.expectedInstanceHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.expectedInstanceHeadDigest) ||
|
||||
typeof record.preparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.preparationDigest) ||
|
||||
typeof record.reconciliationEvidenceDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.reconciliationEvidenceDigest) ||
|
||||
typeof record.applicationConfigDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.applicationConfigDigest) ||
|
||||
typeof record.commitmentFileDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.commitmentFileDigest) ||
|
||||
typeof record.targetDescriptorDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.targetDescriptorDigest) ||
|
||||
!Number.isSafeInteger(record.requestedAtMs) ||
|
||||
(record.requestedAtMs as number) < 0 ||
|
||||
typeof authorizationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(authorizationDigest) ||
|
||||
digest(payload) !== authorizationDigest
|
||||
) {
|
||||
configurationError('legacy start authorization drifted');
|
||||
}
|
||||
return record as unknown as Readonly<LocalServiceManagerLegacyStartAuthorization>;
|
||||
}
|
||||
|
||||
export function localServiceManagerRollbackObservationDigest(
|
||||
value: Omit<LocalServiceManagerRollbackObservation, 'observationDigest'>,
|
||||
): string {
|
||||
return digest(value);
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerRollbackObservation(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerRollbackObservation> {
|
||||
const observation = object(value, 'legacy rollback observation');
|
||||
exact(
|
||||
observation,
|
||||
[
|
||||
'activeState',
|
||||
'enabledState',
|
||||
'fragmentPath',
|
||||
'loadState',
|
||||
'mainPid',
|
||||
'managerKind',
|
||||
'observationDigest',
|
||||
'observedAtMs',
|
||||
'serviceName',
|
||||
'subState',
|
||||
],
|
||||
'legacy rollback observation',
|
||||
);
|
||||
const payload = Object.freeze({
|
||||
managerKind: observation.managerKind,
|
||||
serviceName: observation.serviceName,
|
||||
fragmentPath: safeAbsolutePath(observation.fragmentPath, 'fragmentPath'),
|
||||
loadState: observation.loadState,
|
||||
activeState: observation.activeState,
|
||||
subState: observation.subState,
|
||||
enabledState: observation.enabledState,
|
||||
mainPid: observation.mainPid,
|
||||
observedAtMs: observation.observedAtMs,
|
||||
});
|
||||
if (
|
||||
(observation.managerKind !== 'systemd' &&
|
||||
observation.managerKind !== 'openrc') ||
|
||||
(observation.serviceName !== 'qinglong' &&
|
||||
observation.serviceName !== 'qinglong3') ||
|
||||
(observation.loadState !== 'loaded' &&
|
||||
observation.loadState !== 'not-found' &&
|
||||
observation.loadState !== 'unknown') ||
|
||||
(observation.activeState !== 'active' &&
|
||||
observation.activeState !== 'inactive' &&
|
||||
observation.activeState !== 'failed' &&
|
||||
observation.activeState !== 'unknown') ||
|
||||
typeof observation.subState !== 'string' ||
|
||||
Buffer.byteLength(observation.subState, 'utf8') > 128 ||
|
||||
(observation.enabledState !== 'enabled' &&
|
||||
observation.enabledState !== 'disabled' &&
|
||||
observation.enabledState !== 'static' &&
|
||||
observation.enabledState !== 'unknown') ||
|
||||
!Number.isSafeInteger(observation.mainPid) ||
|
||||
(observation.mainPid as number) < 0 ||
|
||||
(observation.mainPid as number) > MAX_PID ||
|
||||
!Number.isSafeInteger(observation.observedAtMs) ||
|
||||
(observation.observedAtMs as number) < 0 ||
|
||||
typeof observation.observationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(observation.observationDigest) ||
|
||||
digest(payload) !== observation.observationDigest
|
||||
) {
|
||||
configurationError('legacy rollback observation is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...(payload as Omit<
|
||||
LocalServiceManagerRollbackObservation,
|
||||
'observationDigest'
|
||||
>),
|
||||
observationDigest: observation.observationDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function localServiceManagerLegacyStartOutcomeDigest(
|
||||
value: Omit<LocalServiceManagerLegacyStartOutcome, 'outcomeDigest'>,
|
||||
): string {
|
||||
return digest(value);
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerLegacyStartOutcome(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyStartOutcome> {
|
||||
const record = object(value, 'legacy start outcome');
|
||||
exact(
|
||||
record,
|
||||
[
|
||||
'activationDigest',
|
||||
'authorizationDigest',
|
||||
'barrierDigest',
|
||||
'completedAtMs',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacyDescriptorDigest',
|
||||
'legacyObservation',
|
||||
'managerKind',
|
||||
'manualReason',
|
||||
'mutationDisposition',
|
||||
'outcomeDigest',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
'targetDescriptorDigest',
|
||||
'targetObservation',
|
||||
],
|
||||
'legacy start outcome',
|
||||
);
|
||||
const legacyObservation = normalizeLocalServiceManagerRollbackObservation(
|
||||
record.legacyObservation,
|
||||
);
|
||||
const targetObservation = normalizeLocalServiceManagerRollbackObservation(
|
||||
record.targetObservation,
|
||||
);
|
||||
const { outcomeDigest, ...rawPayload } = record;
|
||||
const payload = {
|
||||
...rawPayload,
|
||||
legacyObservation,
|
||||
targetObservation,
|
||||
};
|
||||
if (
|
||||
record.schema !== OUTCOME_SCHEMA ||
|
||||
record.schemaVersion !== 1 ||
|
||||
(record.state !== 'legacy_running' && record.state !== 'manual_required') ||
|
||||
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) ||
|
||||
(record.managerKind !== 'systemd' && record.managerKind !== 'openrc') ||
|
||||
typeof record.preparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.preparationDigest) ||
|
||||
typeof record.authorizationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.authorizationDigest) ||
|
||||
typeof record.barrierDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.barrierDigest) ||
|
||||
typeof record.legacyDescriptorDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.legacyDescriptorDigest) ||
|
||||
typeof record.targetDescriptorDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.targetDescriptorDigest) ||
|
||||
(record.mutationDisposition !== 'executed' &&
|
||||
record.mutationDisposition !== 'response-loss-inspected' &&
|
||||
record.mutationDisposition !== 'replay-inspected') ||
|
||||
(record.manualReason !== null &&
|
||||
record.manualReason !== 'start_precondition_unproved' &&
|
||||
record.manualReason !== 'authorization_material_drifted' &&
|
||||
record.manualReason !== 'manager_command_failed' &&
|
||||
record.manualReason !== 'manager_state_unproved' &&
|
||||
record.manualReason !== 'service_descriptor_drifted') ||
|
||||
(record.state === 'manual_required') !== (record.manualReason !== null) ||
|
||||
legacyObservation.managerKind !== record.managerKind ||
|
||||
legacyObservation.serviceName !== 'qinglong' ||
|
||||
targetObservation.managerKind !== record.managerKind ||
|
||||
targetObservation.serviceName !== 'qinglong3' ||
|
||||
!Number.isSafeInteger(record.completedAtMs) ||
|
||||
(record.completedAtMs as number) < legacyObservation.observedAtMs ||
|
||||
(record.completedAtMs as number) < targetObservation.observedAtMs ||
|
||||
typeof outcomeDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(outcomeDigest) ||
|
||||
digest(payload) !== outcomeDigest
|
||||
) {
|
||||
configurationError('legacy start outcome drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
...(payload as Omit<
|
||||
LocalServiceManagerLegacyStartOutcome,
|
||||
'outcomeDigest'
|
||||
>),
|
||||
outcomeDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalServiceManagerLegacyRollbackBridgeCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackBridgeCommand> {
|
||||
const command = object(value, 'legacy rollback bridge command');
|
||||
exact(
|
||||
command,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'legacy rollback bridge command',
|
||||
);
|
||||
const options = object(command.options, 'options');
|
||||
exact(
|
||||
options,
|
||||
['allowRootController', 'controllerRoot', 'deploymentRoot', 'manager'],
|
||||
'options',
|
||||
);
|
||||
const request = object(command.request, 'request');
|
||||
exact(
|
||||
request,
|
||||
['cutoverId', 'expectedAuthorizationDigest', 'generation'],
|
||||
'request',
|
||||
);
|
||||
const manager = normalizedManager(options.manager);
|
||||
if (
|
||||
command.schemaVersion !== 1 ||
|
||||
command.operation !==
|
||||
'local.deployment.service-manager.legacy-rollback.execute' ||
|
||||
options.allowRootController !== true ||
|
||||
typeof request.cutoverId !== 'string' ||
|
||||
!CUTOVER_ID_PATTERN.test(request.cutoverId) ||
|
||||
!Number.isSafeInteger(request.generation) ||
|
||||
(request.generation as number) < 1 ||
|
||||
(request.generation as number) > MAX_GENERATION ||
|
||||
typeof request.expectedAuthorizationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(request.expectedAuthorizationDigest)
|
||||
) {
|
||||
configurationError('legacy rollback bridge command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation:
|
||||
'local.deployment.service-manager.legacy-rollback.execute' as const,
|
||||
options: Object.freeze({
|
||||
deploymentRoot: safeAbsolutePath(
|
||||
options.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
),
|
||||
controllerRoot: safeAbsolutePath(
|
||||
options.controllerRoot,
|
||||
'controllerRoot',
|
||||
),
|
||||
allowRootController: true as const,
|
||||
manager,
|
||||
}),
|
||||
request: Object.freeze({
|
||||
cutoverId: request.cutoverId,
|
||||
generation: request.generation as number,
|
||||
expectedAuthorizationDigest: request.expectedAuthorizationDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
+69
-20
@@ -1,5 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -11,21 +10,26 @@ import {
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
} from '../cutover/instanceLineage';
|
||||
} 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';
|
||||
} 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';
|
||||
type LocalServiceManagerKind,
|
||||
} from '../serviceBridgeContract';
|
||||
import { readServiceBridgeFile } from '../serviceBridgeFiles';
|
||||
import { normalizeLocalServiceManagerCutoverRecord } from '../serviceCutoverJournal';
|
||||
|
||||
const SCHEMA = 'qinglong3-local-service-manager-rollback-preparation';
|
||||
const ZERO_DIGEST = '0'.repeat(64);
|
||||
@@ -78,11 +82,14 @@ export interface LocalServiceManagerLegacyRollbackPreparation {
|
||||
readonly instanceId: string;
|
||||
readonly generation: number;
|
||||
readonly activationDigest: string;
|
||||
readonly managerKind: LocalServiceManagerKind;
|
||||
readonly expectedInstanceHeadDigest: string;
|
||||
readonly stoppedRecordDigest: string;
|
||||
readonly stoppedManagerOutcomeDigest: string;
|
||||
readonly applicationConfigDigest: string;
|
||||
readonly commitmentDigest: string;
|
||||
readonly commitmentFileDigest: string;
|
||||
readonly targetDescriptorDigest: string;
|
||||
readonly shutdownReceiptDigest: string;
|
||||
readonly reconciliation: Readonly<TargetDataReconciliationEvidence>;
|
||||
readonly requestedAtMs: number;
|
||||
@@ -95,6 +102,7 @@ interface AdoptedPaths {
|
||||
readonly targetDatabasePath: string;
|
||||
readonly expectedActivationDigest: string;
|
||||
readonly commitmentDigest: string;
|
||||
readonly commitmentFileDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
@@ -253,17 +261,20 @@ export function localServiceManagerLegacyRollbackPreparationPath(
|
||||
);
|
||||
}
|
||||
|
||||
function applicationSha256(filePath: string): string {
|
||||
function privateFileSha256(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
label: string,
|
||||
): string {
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(filePath))
|
||||
.digest('hex');
|
||||
bytes = readServiceBridgeFile(filePath, { uid, gid, mode: 0o600 }, label);
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
} catch (error) {
|
||||
return configurationError(
|
||||
'application configuration cannot be hashed',
|
||||
error,
|
||||
);
|
||||
return configurationError(`${label} cannot be hashed`, error);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,8 +283,17 @@ function adoptedPaths(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
|
||||
expectedApplicationDigest: string,
|
||||
expectedCommitmentDigest: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Readonly<AdoptedPaths> {
|
||||
if (applicationSha256(applicationPath) !== expectedApplicationDigest) {
|
||||
if (
|
||||
privateFileSha256(
|
||||
applicationPath,
|
||||
uid,
|
||||
gid,
|
||||
'application configuration',
|
||||
) !== expectedApplicationDigest
|
||||
) {
|
||||
configurationError('adopted application configuration digest drifted');
|
||||
}
|
||||
const application = object(
|
||||
@@ -329,6 +349,12 @@ function adoptedPaths(
|
||||
),
|
||||
expectedActivationDigest: command.request.expectedActivationDigest,
|
||||
commitmentDigest: expectedCommitmentDigest,
|
||||
commitmentFileDigest: privateFileSha256(
|
||||
commitmentPath,
|
||||
uid,
|
||||
gid,
|
||||
'legacy silence commitment',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,6 +362,9 @@ function preparationRecord(
|
||||
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
|
||||
stopped: ReturnType<typeof normalizeLocalServiceManagerCutoverRecord>,
|
||||
reconciliation: Readonly<TargetDataReconciliationEvidence>,
|
||||
managerKind: LocalServiceManagerKind,
|
||||
commitmentFileDigest: string,
|
||||
targetDescriptorDigest: string,
|
||||
): Readonly<LocalServiceManagerLegacyRollbackPreparation> {
|
||||
if (
|
||||
stopped.evidence.shutdownReceiptDigest === null ||
|
||||
@@ -352,11 +381,14 @@ function preparationRecord(
|
||||
instanceId: command.request.instanceId,
|
||||
generation: command.request.generation,
|
||||
activationDigest: command.request.expectedActivationDigest,
|
||||
managerKind,
|
||||
expectedInstanceHeadDigest: command.request.expectedInstanceHeadDigest,
|
||||
stoppedRecordDigest: stopped.recordDigest,
|
||||
stoppedManagerOutcomeDigest: stopped.evidence.managerOutcomeDigest,
|
||||
applicationConfigDigest: stopped.evidence.applicationConfigDigest,
|
||||
commitmentDigest: stopped.evidence.commitmentDigest,
|
||||
commitmentFileDigest,
|
||||
targetDescriptorDigest,
|
||||
shutdownReceiptDigest: stopped.evidence.shutdownReceiptDigest,
|
||||
reconciliation,
|
||||
requestedAtMs: command.request.requestedAtMs,
|
||||
@@ -377,10 +409,12 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
|
||||
'activationDigest',
|
||||
'applicationConfigDigest',
|
||||
'commitmentDigest',
|
||||
'commitmentFileDigest',
|
||||
'cutoverId',
|
||||
'expectedInstanceHeadDigest',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'managerKind',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'reconciliation',
|
||||
@@ -391,6 +425,7 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
|
||||
'state',
|
||||
'stoppedManagerOutcomeDigest',
|
||||
'stoppedRecordDigest',
|
||||
'targetDescriptorDigest',
|
||||
],
|
||||
'service manager rollback preparation',
|
||||
);
|
||||
@@ -413,6 +448,7 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
|
||||
(record.generation as number) > MAX_GENERATION ||
|
||||
typeof record.activationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.activationDigest) ||
|
||||
(record.managerKind !== 'systemd' && record.managerKind !== 'openrc') ||
|
||||
typeof record.expectedInstanceHeadDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.expectedInstanceHeadDigest) ||
|
||||
typeof record.stoppedRecordDigest !== 'string' ||
|
||||
@@ -423,6 +459,10 @@ export function normalizeLocalServiceManagerLegacyRollbackPreparation(
|
||||
!DIGEST_PATTERN.test(record.applicationConfigDigest) ||
|
||||
typeof record.commitmentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.commitmentDigest) ||
|
||||
typeof record.commitmentFileDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.commitmentFileDigest) ||
|
||||
typeof record.targetDescriptorDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.targetDescriptorDigest) ||
|
||||
typeof record.shutdownReceiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.shutdownReceiptDigest) ||
|
||||
reconciliation.disposition !== 'rollback_candidate' ||
|
||||
@@ -531,6 +571,8 @@ export function prepareLocalServiceManagerLegacyRollback(
|
||||
command,
|
||||
stopped.evidence.applicationConfigDigest,
|
||||
stopped.evidence.commitmentDigest,
|
||||
identity.uid,
|
||||
identity.gid,
|
||||
);
|
||||
const reconciliation = readTargetDataReconciliationEvidenceForPaths(
|
||||
{
|
||||
@@ -555,7 +597,14 @@ export function prepareLocalServiceManagerLegacyRollback(
|
||||
head.headDigest,
|
||||
);
|
||||
}
|
||||
const preparation = preparationRecord(command, stopped, reconciliation);
|
||||
const preparation = preparationRecord(
|
||||
command,
|
||||
stopped,
|
||||
reconciliation,
|
||||
intent.service.kind,
|
||||
paths.commitmentFileDigest,
|
||||
intent.descriptor.sha256,
|
||||
);
|
||||
const filePath = localServiceManagerLegacyRollbackPreparationPath(
|
||||
command.options.deploymentRoot,
|
||||
command.request.cutoverId,
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runLocalServiceBridgeCommandFile } from './serviceBridge';
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { runLocalServiceBridge } from './serviceBridge';
|
||||
import { runLocalServiceManagerLegacyRollbackBridge } from './legacy-rollback/bridge';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-service-bridge run --command-file /absolute/root-owned-command.json';
|
||||
@@ -21,7 +24,15 @@ function main(argv: readonly string[]): void {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = runLocalServiceBridgeCommandFile(argv[2]!);
|
||||
const command = readPrivateLocalCommandFile(argv[2]!);
|
||||
const operation =
|
||||
command && typeof command === 'object' && !Array.isArray(command)
|
||||
? (command as Record<string, unknown>).operation
|
||||
: undefined;
|
||||
const result =
|
||||
operation === 'local.deployment.service-manager.legacy-rollback.execute'
|
||||
? runLocalServiceManagerLegacyRollbackBridge(command)
|
||||
: runLocalServiceBridge(command);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
if (result.state === 'manual_required') process.exitCode = 2;
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,7 +11,16 @@ const {
|
||||
const {
|
||||
localServiceManagerLegacyRollbackPreparationPath,
|
||||
prepareLocalServiceManagerLegacyRollback,
|
||||
} = require('../dist/deployment/service-manager/serviceLegacyRollback.js');
|
||||
} = require('../dist/deployment/service-manager/legacy-rollback/preparation.js');
|
||||
const {
|
||||
authorizeLocalServiceManagerLegacyRollback,
|
||||
consumeLocalServiceManagerLegacyRollback,
|
||||
} = require('../dist/deployment/service-manager/legacy-rollback/consumer.js');
|
||||
const {
|
||||
localServiceManagerLegacyStartOutcomeDigest,
|
||||
localServiceManagerLegacyStartOutcomePath,
|
||||
localServiceManagerRollbackObservationDigest,
|
||||
} = require('../dist/deployment/service-manager/legacy-rollback/contract.js');
|
||||
const {
|
||||
prepareLocalServiceManagerIntent,
|
||||
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
|
||||
@@ -384,6 +393,126 @@ function rollbackPrepareCommand(state, stoppedResult, head) {
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackAuthorizeCommand(state, prepared) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.authorize',
|
||||
options: {
|
||||
deploymentRoot: state.root,
|
||||
allowRootService: process.getuid() === 0,
|
||||
},
|
||||
request: {
|
||||
cutoverId: state.cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
generation: 1,
|
||||
expectedActivationDigest: state.activationDigest,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
expectedInstanceHeadDigest: prepared.instanceHeadDigest,
|
||||
requestedAtMs: 1786416000500,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackObservation(serviceName, active, observedAtMs) {
|
||||
const payload = {
|
||||
managerKind: 'systemd',
|
||||
serviceName,
|
||||
fragmentPath: `/etc/systemd/system/${serviceName}.service`,
|
||||
loadState: 'loaded',
|
||||
activeState: active ? 'active' : 'inactive',
|
||||
subState: active ? 'running' : 'dead',
|
||||
enabledState: 'enabled',
|
||||
mainPid: active ? 5723 : 0,
|
||||
observedAtMs,
|
||||
};
|
||||
return {
|
||||
...payload,
|
||||
observationDigest: localServiceManagerRollbackObservationDigest(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function publishLegacyStartOutcome(
|
||||
state,
|
||||
prepared,
|
||||
authorized,
|
||||
outcomeState = 'legacy_running',
|
||||
) {
|
||||
const preparation = JSON.parse(
|
||||
fs.readFileSync(
|
||||
localServiceManagerLegacyRollbackPreparationPath(
|
||||
state.root,
|
||||
state.cutoverId,
|
||||
1,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const legacyObservation = rollbackObservation(
|
||||
'qinglong',
|
||||
outcomeState === 'legacy_running',
|
||||
1786416000600,
|
||||
);
|
||||
const targetObservation = rollbackObservation(
|
||||
'qinglong3',
|
||||
false,
|
||||
1786416000601,
|
||||
);
|
||||
const payload = {
|
||||
schema: 'qinglong3-local-service-manager-legacy-start-outcome',
|
||||
schemaVersion: 1,
|
||||
state: outcomeState,
|
||||
cutoverId: state.cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
generation: 1,
|
||||
activationDigest: state.activationDigest,
|
||||
managerKind: 'systemd',
|
||||
preparationDigest: prepared.preparationDigest,
|
||||
authorizationDigest: authorized.authorizationDigest,
|
||||
barrierDigest: '9'.repeat(64),
|
||||
legacyDescriptorDigest: 'a'.repeat(64),
|
||||
targetDescriptorDigest: preparation.targetDescriptorDigest,
|
||||
mutationDisposition: 'executed',
|
||||
manualReason:
|
||||
outcomeState === 'legacy_running' ? null : 'manager_state_unproved',
|
||||
legacyObservation,
|
||||
targetObservation,
|
||||
completedAtMs: 1786416000610,
|
||||
};
|
||||
const outcome = {
|
||||
...payload,
|
||||
outcomeDigest: localServiceManagerLegacyStartOutcomeDigest(payload),
|
||||
};
|
||||
writePrivate(
|
||||
localServiceManagerLegacyStartOutcomePath(state.root, state.cutoverId, 1),
|
||||
outcome,
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function rollbackConsumeCommand(state, prepared, authorized) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.consume',
|
||||
options: {
|
||||
deploymentRoot: state.root,
|
||||
allowRootService: process.getuid() === 0,
|
||||
},
|
||||
request: {
|
||||
cutoverId: state.cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
generation: 1,
|
||||
expectedActivationDigest: state.activationDigest,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
expectedAuthorizationDigest: authorized.authorizationDigest,
|
||||
expectedAuthorizationHeadDigest: authorized.instanceHeadDigest,
|
||||
requestedAtMs: 1786416000700,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('commits adopted service active evidence and replays from the instance head', async (t) => {
|
||||
const state = fixture(t);
|
||||
const prepared = prepare(
|
||||
@@ -574,6 +703,92 @@ test('prepares and exactly replays lossless service-manager legacy rollback evid
|
||||
assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest);
|
||||
});
|
||||
|
||||
test('authorizes, consumes and exactly replays service-manager legacy running evidence', async (t) => {
|
||||
const state = fixture(t);
|
||||
const { stoppedResult, head } = await stopAdoptedTarget(state, '039');
|
||||
const prepared = prepareLocalServiceManagerLegacyRollback(
|
||||
rollbackPrepareCommand(state, stoppedResult, head),
|
||||
);
|
||||
const authorizeCommand = rollbackAuthorizeCommand(state, prepared);
|
||||
const authorized =
|
||||
authorizeLocalServiceManagerLegacyRollback(authorizeCommand);
|
||||
assert.equal(authorized.status, 'prepared');
|
||||
assert.equal(authorized.state, 'legacy_restart_requested');
|
||||
assert.equal(
|
||||
authorizeLocalServiceManagerLegacyRollback(authorizeCommand).status,
|
||||
'existing',
|
||||
);
|
||||
const requestedHead = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(requestedHead.state, 'legacy_restart_requested');
|
||||
assert.equal(
|
||||
requestedHead.sourceRecordDigest,
|
||||
authorized.authorizationDigest,
|
||||
);
|
||||
const outcome = publishLegacyStartOutcome(state, prepared, authorized);
|
||||
const consumeCommand = rollbackConsumeCommand(state, prepared, authorized);
|
||||
const consumed = consumeLocalServiceManagerLegacyRollback(consumeCommand);
|
||||
assert.equal(consumed.status, 'prepared');
|
||||
assert.equal(consumed.state, 'legacy_running');
|
||||
assert.equal(consumed.outcomeDigest, outcome.outcomeDigest);
|
||||
assert.match(consumed.completionDigest, /^[0-9a-f]{64}$/);
|
||||
const replay = consumeLocalServiceManagerLegacyRollback(consumeCommand);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.completionDigest, consumed.completionDigest);
|
||||
assert.equal(replay.instanceHeadDigest, consumed.instanceHeadDigest);
|
||||
});
|
||||
|
||||
test('requires current rollback evidence before publishing legacy start authorization', async (t) => {
|
||||
const state = fixture(t);
|
||||
const { stoppedResult, head } = await stopAdoptedTarget(state, '041');
|
||||
const prepared = prepareLocalServiceManagerLegacyRollback(
|
||||
rollbackPrepareCommand(state, stoppedResult, head),
|
||||
);
|
||||
fs.writeFileSync(state.targetPath, 'target-drift-before-authorization\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
authorizeLocalServiceManagerLegacyRollback(
|
||||
rollbackAuthorizeCommand(state, prepared),
|
||||
),
|
||||
/prepared rollback data drifted after authorization/,
|
||||
);
|
||||
const unchanged = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(unchanged.state, 'rollback_prepared');
|
||||
assert.equal(unchanged.headDigest, prepared.instanceHeadDigest);
|
||||
});
|
||||
|
||||
test('terminalizes an unproved root legacy start outcome', async (t) => {
|
||||
const state = fixture(t);
|
||||
const { stoppedResult, head } = await stopAdoptedTarget(state, '043');
|
||||
const prepared = prepareLocalServiceManagerLegacyRollback(
|
||||
rollbackPrepareCommand(state, stoppedResult, head),
|
||||
);
|
||||
const authorized = authorizeLocalServiceManagerLegacyRollback(
|
||||
rollbackAuthorizeCommand(state, prepared),
|
||||
);
|
||||
publishLegacyStartOutcome(state, prepared, authorized, 'manual_required');
|
||||
const consumed = consumeLocalServiceManagerLegacyRollback(
|
||||
rollbackConsumeCommand(state, prepared, authorized),
|
||||
);
|
||||
assert.equal(consumed.state, 'manual_required');
|
||||
const terminal = readLocalCutoverInstanceHead(
|
||||
state.root,
|
||||
'edge-router-1',
|
||||
process.getuid(),
|
||||
);
|
||||
assert.equal(terminal.state, 'manual_required');
|
||||
assert.equal(terminal.sourceRecordDigest, consumed.completionDigest);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
localServiceManagerLegacyStartOutcomeDigest,
|
||||
localServiceManagerRollbackObservationDigest,
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand,
|
||||
normalizeLocalServiceManagerLegacyStartOutcome,
|
||||
} = require('../dist/deployment/service-manager/legacy-rollback/contract.js');
|
||||
|
||||
function command(kind = 'systemd') {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.execute',
|
||||
options: {
|
||||
deploymentRoot: '/var/lib/qinglong3',
|
||||
controllerRoot: '/var/lib/qinglong3-service-bridge',
|
||||
allowRootController: true,
|
||||
manager:
|
||||
kind === 'systemd'
|
||||
? { kind, executable: '/usr/bin/systemctl' }
|
||||
: {
|
||||
kind,
|
||||
serviceExecutable: '/sbin/rc-service',
|
||||
updateExecutable: '/sbin/rc-update',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
cutoverId: 'edge-router-cutover',
|
||||
generation: 1,
|
||||
expectedAuthorizationDigest: '1'.repeat(64),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function observation(serviceName, active, observedAtMs) {
|
||||
const payload = {
|
||||
managerKind: 'systemd',
|
||||
serviceName,
|
||||
fragmentPath: `/etc/systemd/system/${serviceName}.service`,
|
||||
loadState: 'loaded',
|
||||
activeState: active ? 'active' : 'inactive',
|
||||
subState: active ? 'running' : 'dead',
|
||||
enabledState: 'enabled',
|
||||
mainPid: active ? 3101 : 0,
|
||||
observedAtMs,
|
||||
};
|
||||
return {
|
||||
...payload,
|
||||
observationDigest: localServiceManagerRollbackObservationDigest(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function outcome() {
|
||||
const payload = {
|
||||
schema: 'qinglong3-local-service-manager-legacy-start-outcome',
|
||||
schemaVersion: 1,
|
||||
state: 'legacy_running',
|
||||
cutoverId: 'edge-router-cutover',
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
generation: 1,
|
||||
activationDigest: '2'.repeat(64),
|
||||
managerKind: 'systemd',
|
||||
preparationDigest: '3'.repeat(64),
|
||||
authorizationDigest: '4'.repeat(64),
|
||||
barrierDigest: '5'.repeat(64),
|
||||
legacyDescriptorDigest: '6'.repeat(64),
|
||||
targetDescriptorDigest: '7'.repeat(64),
|
||||
mutationDisposition: 'response-loss-inspected',
|
||||
manualReason: null,
|
||||
legacyObservation: observation('qinglong', true, 100),
|
||||
targetObservation: observation('qinglong3', false, 101),
|
||||
completedAtMs: 102,
|
||||
};
|
||||
return {
|
||||
...payload,
|
||||
outcomeDigest: localServiceManagerLegacyStartOutcomeDigest(payload),
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes exact systemd and OpenRC legacy rollback bridge commands', () => {
|
||||
assert.equal(
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand(command()).options
|
||||
.manager.kind,
|
||||
'systemd',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand(command('openrc'))
|
||||
.options.manager.kind,
|
||||
'openrc',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects shell surface, path drift and unbound root acknowledgement', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand({
|
||||
...command(),
|
||||
shell: 'systemctl start qinglong',
|
||||
}),
|
||||
/shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand({
|
||||
...command(),
|
||||
options: { ...command().options, deploymentRoot: '/var/lib/../tmp' },
|
||||
}),
|
||||
/supervisor-safe absolute path/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalServiceManagerLegacyRollbackBridgeCommand({
|
||||
...command(),
|
||||
options: { ...command().options, allowRootController: false },
|
||||
}),
|
||||
/command is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('binds both service observations and all rollback outcome digests', () => {
|
||||
const value = outcome();
|
||||
const normalized = normalizeLocalServiceManagerLegacyStartOutcome(value);
|
||||
assert.equal(normalized.state, 'legacy_running');
|
||||
assert.equal(normalized.legacyObservation.serviceName, 'qinglong');
|
||||
assert.equal(normalized.targetObservation.serviceName, 'qinglong3');
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalServiceManagerLegacyStartOutcome({
|
||||
...value,
|
||||
targetDescriptorDigest: '8'.repeat(64),
|
||||
}),
|
||||
/outcome drifted/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalServiceManagerLegacyStartOutcome({
|
||||
...value,
|
||||
legacyObservation: value.targetObservation,
|
||||
}),
|
||||
/outcome drifted/,
|
||||
);
|
||||
});
|
||||
@@ -24,16 +24,19 @@ function main() {
|
||||
'ql3-service-bridge binary entry drifted',
|
||||
);
|
||||
}
|
||||
const entry = path.join(
|
||||
packageRoot,
|
||||
const entries = [
|
||||
'dist/deployment/service-manager/serviceBridge.js',
|
||||
);
|
||||
if (!fs.existsSync(entry)) {
|
||||
throw new QingLong3ServiceBridgeImportAuditError(
|
||||
'service bridge must be built before import audit',
|
||||
);
|
||||
'dist/deployment/service-manager/legacy-rollback/bridge.js',
|
||||
];
|
||||
for (const relativeEntry of entries) {
|
||||
const entry = path.join(packageRoot, relativeEntry);
|
||||
if (!fs.existsSync(entry)) {
|
||||
throw new QingLong3ServiceBridgeImportAuditError(
|
||||
`${relativeEntry} must be built before import audit`,
|
||||
);
|
||||
}
|
||||
require(entry);
|
||||
}
|
||||
require(entry);
|
||||
const loaded = Object.keys(require.cache)
|
||||
.map((filePath) => path.resolve(filePath))
|
||||
.filter(
|
||||
@@ -69,8 +72,14 @@ function main() {
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/service-manager/serviceBridgeCli.ts',
|
||||
});
|
||||
}
|
||||
if (!source.includes("from './legacy-rollback/bridge'")) {
|
||||
findings.push({
|
||||
code: 'LEGACY_ROLLBACK_BRIDGE_CLI_ENTRY_DRIFT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/service-manager/serviceBridgeCli.ts',
|
||||
});
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
compatible: findings.length === 0,
|
||||
binary: expectedBin,
|
||||
loaded,
|
||||
|
||||
@@ -61,7 +61,7 @@ function waitReady(name, kind) {
|
||||
fail(`${kind} container did not become ready`);
|
||||
}
|
||||
|
||||
function actorReport(name, kind, identityMode) {
|
||||
function actorReport(name, kind, identityMode, scenario = 'success') {
|
||||
const stdout = run([
|
||||
'exec',
|
||||
name,
|
||||
@@ -69,6 +69,7 @@ function actorReport(name, kind, identityMode) {
|
||||
'/workspace/scripts/ql3-service-manager-bridge-live-actor.cjs',
|
||||
kind,
|
||||
identityMode,
|
||||
scenario,
|
||||
]);
|
||||
const report = JSON.parse(stdout);
|
||||
const requiredGates = [
|
||||
@@ -84,10 +85,28 @@ function actorReport(name, kind, identityMode) {
|
||||
'adoptedRollbackPrepared',
|
||||
'adoptedRollbackReplay',
|
||||
'adoptedRollbackCandidate',
|
||||
'adoptedRollbackAuthorized',
|
||||
'adoptedRollbackAuthorizationReplay',
|
||||
'adoptedLegacyStartReplay',
|
||||
'adoptedLegacyConsumed',
|
||||
'adoptedTargetRemainedStopped',
|
||||
...(scenario === 'success'
|
||||
? [
|
||||
'adoptedLegacyStarted',
|
||||
'adoptedLegacyProcessIdentity',
|
||||
'adoptedLegacyResponseLossInspected',
|
||||
]
|
||||
: [
|
||||
'adoptedLegacyBarrierCrash',
|
||||
'adoptedLegacyInspectOnlyConvergence',
|
||||
'adoptedLegacyRemainedStopped',
|
||||
'adoptedLegacyManualConsumed',
|
||||
]),
|
||||
];
|
||||
if (
|
||||
report.managerKind !== kind ||
|
||||
report.identityMode !== identityMode ||
|
||||
report.scenario !== scenario ||
|
||||
report.serviceUid !== (identityMode === 'root' ? 0 : 10001) ||
|
||||
!requiredGates.every((gate) => report.gates?.[gate] === true) ||
|
||||
(report.gates?.restartRequired === true &&
|
||||
@@ -152,10 +171,21 @@ function main() {
|
||||
);
|
||||
waitReady(name, target.kind);
|
||||
reports.push(actorReport(name, target.kind, identityMode));
|
||||
if (identityMode === 'root') {
|
||||
reports.push(
|
||||
actorReport(name, target.kind, identityMode, 'barrier-crash'),
|
||||
);
|
||||
}
|
||||
run(['rm', '--force', name]);
|
||||
containers.splice(containers.indexOf(name), 1);
|
||||
}
|
||||
}
|
||||
const successReports = reports.filter(
|
||||
(report) => report.scenario === 'success',
|
||||
);
|
||||
const crashReports = reports.filter(
|
||||
(report) => report.scenario === 'barrier-crash',
|
||||
);
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
evidenceClass: 'qinglong3_service_manager_bridge_docker_gate',
|
||||
@@ -179,31 +209,65 @@ function main() {
|
||||
report.managerKind === 'openrc' &&
|
||||
report.identityMode === 'nonroot',
|
||||
),
|
||||
rootCommandFile: reports.every(
|
||||
rootCommandFile: successReports.every(
|
||||
(report) => report.gates.rootCommandFile,
|
||||
),
|
||||
exactReplay: reports.every((report) => report.gates.exactReplay),
|
||||
ownerOutcomeVerified: reports.every(
|
||||
exactReplay: successReports.every((report) => report.gates.exactReplay),
|
||||
ownerOutcomeVerified: successReports.every(
|
||||
(report) => report.gates.ownerOutcomeVerified,
|
||||
),
|
||||
serviceProcessIdentity: reports.every(
|
||||
serviceProcessIdentity: successReports.every(
|
||||
(report) => report.gates.serviceProcessIdentity,
|
||||
),
|
||||
adoptedCutoverActive: reports.every(
|
||||
adoptedCutoverActive: successReports.every(
|
||||
(report) => report.gates.adoptedCutoverActive,
|
||||
),
|
||||
adoptedCutoverStopped: reports.every(
|
||||
adoptedCutoverStopped: successReports.every(
|
||||
(report) => report.gates.adoptedCutoverStopped,
|
||||
),
|
||||
adoptedRollbackPrepared: reports.every(
|
||||
adoptedRollbackPrepared: successReports.every(
|
||||
(report) => report.gates.adoptedRollbackPrepared,
|
||||
),
|
||||
adoptedRollbackReplay: reports.every(
|
||||
adoptedRollbackReplay: successReports.every(
|
||||
(report) => report.gates.adoptedRollbackReplay,
|
||||
),
|
||||
adoptedRollbackCandidate: reports.every(
|
||||
adoptedRollbackCandidate: successReports.every(
|
||||
(report) => report.gates.adoptedRollbackCandidate,
|
||||
),
|
||||
adoptedRollbackAuthorized: successReports.every(
|
||||
(report) => report.gates.adoptedRollbackAuthorized,
|
||||
),
|
||||
adoptedRollbackAuthorizationReplay: successReports.every(
|
||||
(report) => report.gates.adoptedRollbackAuthorizationReplay,
|
||||
),
|
||||
adoptedLegacyStarted: successReports.every(
|
||||
(report) => report.gates.adoptedLegacyStarted,
|
||||
),
|
||||
adoptedLegacyStartReplay: successReports.every(
|
||||
(report) => report.gates.adoptedLegacyStartReplay,
|
||||
),
|
||||
adoptedLegacyConsumed: successReports.every(
|
||||
(report) => report.gates.adoptedLegacyConsumed,
|
||||
),
|
||||
adoptedTargetRemainedStopped: successReports.every(
|
||||
(report) => report.gates.adoptedTargetRemainedStopped,
|
||||
),
|
||||
adoptedLegacyProcessIdentity: successReports.every(
|
||||
(report) => report.gates.adoptedLegacyProcessIdentity,
|
||||
),
|
||||
adoptedLegacyResponseLossInspected: successReports.every(
|
||||
(report) => report.gates.adoptedLegacyResponseLossInspected,
|
||||
),
|
||||
adoptedLegacyBarrierCrash: crashReports.length === 2,
|
||||
adoptedLegacyInspectOnlyConvergence: crashReports.every(
|
||||
(report) => report.gates.adoptedLegacyInspectOnlyConvergence,
|
||||
),
|
||||
adoptedLegacyRemainedStopped: crashReports.every(
|
||||
(report) => report.gates.adoptedLegacyRemainedStopped,
|
||||
),
|
||||
adoptedLegacyManualConsumed: crashReports.every(
|
||||
(report) => report.gates.adoptedLegacyManualConsumed,
|
||||
),
|
||||
systemdRestart: reports.some(
|
||||
(report) =>
|
||||
report.managerKind === 'systemd' && report.gates.restarted,
|
||||
|
||||
@@ -20,6 +20,12 @@ const {
|
||||
const {
|
||||
runLocalServiceBridgeCommandFile,
|
||||
} = require('../packages/ql3-local-owner-cli/dist/deployment/service-manager/serviceBridge.js');
|
||||
const {
|
||||
runLocalServiceManagerLegacyRollbackBridgeCommandFile,
|
||||
} = require('../packages/ql3-local-owner-cli/dist/deployment/service-manager/legacy-rollback/bridge.js');
|
||||
const {
|
||||
localServiceManagerLegacyStartOutcomePath,
|
||||
} = require('../packages/ql3-local-owner-cli/dist/deployment/service-manager/legacy-rollback/contract.js');
|
||||
|
||||
const OWNER_DEPLOYMENT_CLI = path.resolve(
|
||||
__dirname,
|
||||
@@ -58,6 +64,30 @@ function run(executable, args, accepted = [0]) {
|
||||
return String(result.stdout ?? '');
|
||||
}
|
||||
|
||||
function responseLossManager(request) {
|
||||
const result = spawnSync(request.executable, [...request.args], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
LANG: 'C',
|
||||
LC_ALL: 'C',
|
||||
},
|
||||
timeout: request.timeoutMs,
|
||||
maxBuffer: 64 * 1024,
|
||||
});
|
||||
const legacyStart =
|
||||
(request.args[0] === 'start' && request.args[1] === 'qinglong.service') ||
|
||||
(request.args[0] === 'qinglong' && request.args[1] === 'start');
|
||||
return {
|
||||
status: legacyStart ? null : result.status,
|
||||
signal: result.signal,
|
||||
stdout: String(result.stdout ?? ''),
|
||||
stderr: String(result.stderr ?? ''),
|
||||
responseLost:
|
||||
legacyStart || result.error !== undefined || result.status === null,
|
||||
};
|
||||
}
|
||||
|
||||
function executable(candidates, label) {
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return fs.realpathSync(candidate);
|
||||
@@ -106,6 +136,37 @@ function descriptor(kind, uid, gid, root, adopted = false) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function legacyDescriptor(kind, uid, gid) {
|
||||
if (kind === 'systemd') {
|
||||
return [
|
||||
'[Unit]',
|
||||
'Description=QingLong 2 legacy rollback live gate',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`User=${uid}`,
|
||||
`Group=${gid}`,
|
||||
'ExecStart=/bin/sleep 300',
|
||||
'Restart=no',
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=multi-user.target',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
'#!/sbin/openrc-run',
|
||||
'name="qinglong"',
|
||||
'description="QingLong 2 legacy rollback live gate"',
|
||||
'command="/bin/sleep"',
|
||||
'command_args="300"',
|
||||
`command_user="${uid}:${gid}"`,
|
||||
'supervisor="supervise-daemon"',
|
||||
'retry="TERM/5/KILL/2"',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function manager(kind) {
|
||||
if (kind === 'systemd') {
|
||||
return {
|
||||
@@ -436,6 +497,222 @@ function prepareAdoptedRollback(root, adopted, stopped, uid, gid) {
|
||||
};
|
||||
}
|
||||
|
||||
function authorizeAdoptedRollback(root, adopted, prepared, uid, gid) {
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.authorize',
|
||||
options: { deploymentRoot: root, allowRootService: uid === 0 },
|
||||
request: {
|
||||
cutoverId: adopted.cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: adopted.instanceId,
|
||||
generation: 1,
|
||||
expectedActivationDigest: adopted.activationDigest,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
expectedInstanceHeadDigest: prepared.instanceHeadDigest,
|
||||
requestedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
const commandPath = path.join(root, 'owner-legacy-rollback-authorize.json');
|
||||
writePrivate(
|
||||
commandPath,
|
||||
`${JSON.stringify(command, null, 2)}\n`,
|
||||
0o600,
|
||||
uid,
|
||||
gid,
|
||||
);
|
||||
return {
|
||||
commandPath,
|
||||
result: ownerCli(
|
||||
uid,
|
||||
gid,
|
||||
'service-legacy-rollback-authorize',
|
||||
commandPath,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function installLegacyService(kind, managerOptions, uid, gid) {
|
||||
const descriptorPath =
|
||||
kind === 'systemd'
|
||||
? '/etc/systemd/system/qinglong.service'
|
||||
: '/etc/init.d/qinglong';
|
||||
fs.rmSync(descriptorPath, { force: true });
|
||||
writePrivate(
|
||||
descriptorPath,
|
||||
legacyDescriptor(kind, uid, gid),
|
||||
kind === 'systemd' ? 0o644 : 0o755,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
if (kind === 'systemd') {
|
||||
run(managerOptions.executable, ['daemon-reload']);
|
||||
}
|
||||
}
|
||||
|
||||
function executeLegacyRollback(
|
||||
root,
|
||||
controllerRoot,
|
||||
managerOptions,
|
||||
adopted,
|
||||
prepared,
|
||||
authorized,
|
||||
uid,
|
||||
gid,
|
||||
) {
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.execute',
|
||||
options: {
|
||||
deploymentRoot: root,
|
||||
controllerRoot,
|
||||
allowRootController: true,
|
||||
manager: managerOptions,
|
||||
},
|
||||
request: {
|
||||
cutoverId: adopted.cutoverId,
|
||||
generation: 1,
|
||||
expectedAuthorizationDigest: authorized.authorizationDigest,
|
||||
},
|
||||
};
|
||||
const commandPath = path.join(root, 'root-legacy-rollback-command.json');
|
||||
writePrivate(commandPath, `${JSON.stringify(command, null, 2)}\n`);
|
||||
const result = runLocalServiceManagerLegacyRollbackBridgeCommandFile(
|
||||
commandPath,
|
||||
{ runManager: responseLossManager },
|
||||
);
|
||||
const replay =
|
||||
runLocalServiceManagerLegacyRollbackBridgeCommandFile(commandPath);
|
||||
const consumeCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.consume',
|
||||
options: { deploymentRoot: root, allowRootService: uid === 0 },
|
||||
request: {
|
||||
cutoverId: adopted.cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: adopted.instanceId,
|
||||
generation: 1,
|
||||
expectedActivationDigest: adopted.activationDigest,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
expectedAuthorizationDigest: authorized.authorizationDigest,
|
||||
expectedAuthorizationHeadDigest: authorized.instanceHeadDigest,
|
||||
requestedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
const consumePath = path.join(root, 'owner-legacy-rollback-consume.json');
|
||||
writePrivate(
|
||||
consumePath,
|
||||
`${JSON.stringify(consumeCommand, null, 2)}\n`,
|
||||
0o600,
|
||||
uid,
|
||||
gid,
|
||||
);
|
||||
const consumed = ownerCli(
|
||||
uid,
|
||||
gid,
|
||||
'service-legacy-rollback-consume',
|
||||
consumePath,
|
||||
);
|
||||
const outcome = JSON.parse(
|
||||
fs.readFileSync(
|
||||
localServiceManagerLegacyStartOutcomePath(root, adopted.cutoverId, 1),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
return { commandPath, result, replay, consumed, outcome };
|
||||
}
|
||||
|
||||
function executeLegacyRollbackBarrierCrash(
|
||||
root,
|
||||
controllerRoot,
|
||||
managerOptions,
|
||||
adopted,
|
||||
prepared,
|
||||
authorized,
|
||||
uid,
|
||||
gid,
|
||||
) {
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.execute',
|
||||
options: {
|
||||
deploymentRoot: root,
|
||||
controllerRoot,
|
||||
allowRootController: true,
|
||||
manager: managerOptions,
|
||||
},
|
||||
request: {
|
||||
cutoverId: adopted.cutoverId,
|
||||
generation: 1,
|
||||
expectedAuthorizationDigest: authorized.authorizationDigest,
|
||||
},
|
||||
};
|
||||
const commandPath = path.join(
|
||||
root,
|
||||
'root-legacy-rollback-barrier-crash-command.json',
|
||||
);
|
||||
writePrivate(commandPath, `${JSON.stringify(command, null, 2)}\n`);
|
||||
let interrupted = false;
|
||||
try {
|
||||
runLocalServiceManagerLegacyRollbackBridgeCommandFile(commandPath, {
|
||||
afterBarrier() {
|
||||
throw new QingLong3ServiceManagerBridgeLiveActorError(
|
||||
'simulated crash after legacy start barrier',
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
interrupted =
|
||||
error instanceof QingLong3ServiceManagerBridgeLiveActorError &&
|
||||
error.message === 'simulated crash after legacy start barrier';
|
||||
}
|
||||
if (!interrupted) fail('legacy start barrier crash was not observed');
|
||||
const result =
|
||||
runLocalServiceManagerLegacyRollbackBridgeCommandFile(commandPath);
|
||||
const replay =
|
||||
runLocalServiceManagerLegacyRollbackBridgeCommandFile(commandPath);
|
||||
const consumeCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.service-manager.legacy-rollback.consume',
|
||||
options: { deploymentRoot: root, allowRootService: uid === 0 },
|
||||
request: {
|
||||
cutoverId: adopted.cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: adopted.instanceId,
|
||||
generation: 1,
|
||||
expectedActivationDigest: adopted.activationDigest,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
expectedAuthorizationDigest: authorized.authorizationDigest,
|
||||
expectedAuthorizationHeadDigest: authorized.instanceHeadDigest,
|
||||
requestedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
const consumePath = path.join(
|
||||
root,
|
||||
'owner-legacy-rollback-barrier-crash-consume.json',
|
||||
);
|
||||
writePrivate(
|
||||
consumePath,
|
||||
`${JSON.stringify(consumeCommand, null, 2)}\n`,
|
||||
0o600,
|
||||
uid,
|
||||
gid,
|
||||
);
|
||||
const consumed = ownerCli(
|
||||
uid,
|
||||
gid,
|
||||
'service-legacy-rollback-consume',
|
||||
consumePath,
|
||||
);
|
||||
const outcome = JSON.parse(
|
||||
fs.readFileSync(
|
||||
localServiceManagerLegacyStartOutcomePath(root, adopted.cutoverId, 1),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
return { commandPath, result, replay, consumed, outcome };
|
||||
}
|
||||
|
||||
function execute(root, controllerRoot, managerOptions, prepared, uid, gid) {
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
@@ -509,24 +786,30 @@ function serviceProcessUid(kind, outcome) {
|
||||
|
||||
function cleanup(kind, managerOptions) {
|
||||
if (kind === 'systemd') {
|
||||
spawnSync(
|
||||
managerOptions.executable,
|
||||
['disable', '--now', 'qinglong3.service'],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
fs.rmSync('/etc/systemd/system/qinglong3.service', { force: true });
|
||||
for (const serviceName of ['qinglong', 'qinglong3']) {
|
||||
spawnSync(
|
||||
managerOptions.executable,
|
||||
['disable', '--now', `${serviceName}.service`],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
fs.rmSync(`/etc/systemd/system/${serviceName}.service`, { force: true });
|
||||
}
|
||||
spawnSync(managerOptions.executable, ['daemon-reload'], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
spawnSync(managerOptions.serviceExecutable, ['qinglong3', 'stop'], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
spawnSync(managerOptions.updateExecutable, ['del', 'qinglong3', 'default'], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
fs.rmSync('/etc/init.d/qinglong3', { force: true });
|
||||
for (const serviceName of ['qinglong', 'qinglong3']) {
|
||||
spawnSync(managerOptions.serviceExecutable, [serviceName, 'stop'], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
spawnSync(
|
||||
managerOptions.updateExecutable,
|
||||
['del', serviceName, 'default'],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
fs.rmSync(`/etc/init.d/${serviceName}`, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
@@ -535,12 +818,14 @@ async function main(argv) {
|
||||
}
|
||||
const kind = argv[0];
|
||||
const identityMode = argv[1];
|
||||
const scenario = argv[2] ?? 'success';
|
||||
if (
|
||||
(kind !== 'systemd' && kind !== 'openrc') ||
|
||||
(identityMode !== 'root' && identityMode !== 'nonroot')
|
||||
(identityMode !== 'root' && identityMode !== 'nonroot') ||
|
||||
(scenario !== 'success' && scenario !== 'barrier-crash')
|
||||
) {
|
||||
fail(
|
||||
'usage: ql3-service-manager-bridge-live-actor.cjs <systemd|openrc> <root|nonroot>',
|
||||
'usage: ql3-service-manager-bridge-live-actor.cjs <systemd|openrc> <root|nonroot> [success|barrier-crash]',
|
||||
);
|
||||
}
|
||||
const uid = identityMode === 'root' ? 0 : NON_ROOT_SERVICE_UID;
|
||||
@@ -728,11 +1013,91 @@ async function main(argv) {
|
||||
) {
|
||||
fail('adopted rollback preparation did not converge exactly');
|
||||
}
|
||||
const adoptedAuthorization = authorizeAdoptedRollback(
|
||||
root,
|
||||
adopted,
|
||||
adoptedRollback.result,
|
||||
uid,
|
||||
gid,
|
||||
);
|
||||
const adoptedAuthorizationReplay = ownerCli(
|
||||
uid,
|
||||
gid,
|
||||
'service-legacy-rollback-authorize',
|
||||
adoptedAuthorization.commandPath,
|
||||
);
|
||||
if (
|
||||
adoptedAuthorization.result.status !== 'prepared' ||
|
||||
adoptedAuthorization.result.state !== 'legacy_restart_requested' ||
|
||||
adoptedAuthorizationReplay.status !== 'existing' ||
|
||||
adoptedAuthorizationReplay.authorizationDigest !==
|
||||
adoptedAuthorization.result.authorizationDigest
|
||||
) {
|
||||
fail('adopted rollback authorization did not converge exactly');
|
||||
}
|
||||
installLegacyService(kind, managerOptions, uid, gid);
|
||||
const adoptedLegacyStarted =
|
||||
scenario === 'success'
|
||||
? executeLegacyRollback(
|
||||
root,
|
||||
controllerRoot,
|
||||
managerOptions,
|
||||
adopted,
|
||||
adoptedRollback.result,
|
||||
adoptedAuthorization.result,
|
||||
uid,
|
||||
gid,
|
||||
)
|
||||
: executeLegacyRollbackBarrierCrash(
|
||||
root,
|
||||
controllerRoot,
|
||||
managerOptions,
|
||||
adopted,
|
||||
adoptedRollback.result,
|
||||
adoptedAuthorization.result,
|
||||
uid,
|
||||
gid,
|
||||
);
|
||||
if (scenario === 'success') {
|
||||
if (
|
||||
adoptedLegacyStarted.result.state !== 'legacy_running' ||
|
||||
adoptedLegacyStarted.replay.status !== 'existing' ||
|
||||
adoptedLegacyStarted.replay.state !== 'legacy_running' ||
|
||||
adoptedLegacyStarted.consumed.state !== 'legacy_running' ||
|
||||
adoptedLegacyStarted.outcome.mutationDisposition !==
|
||||
'response-loss-inspected' ||
|
||||
adoptedLegacyStarted.outcome.targetObservation.activeState !==
|
||||
'inactive' ||
|
||||
adoptedLegacyStarted.outcome.legacyObservation.activeState !== 'active'
|
||||
) {
|
||||
fail('adopted legacy rollback did not converge to legacy_running');
|
||||
}
|
||||
const legacyUid = serviceProcessUid(kind, {
|
||||
observation: adoptedLegacyStarted.outcome.legacyObservation,
|
||||
});
|
||||
if (legacyUid !== uid) {
|
||||
fail(`legacy service process UID drifted: ${legacyUid} != ${uid}`);
|
||||
}
|
||||
} else if (
|
||||
adoptedLegacyStarted.result.state !== 'manual_required' ||
|
||||
adoptedLegacyStarted.result.status !== 'prepared' ||
|
||||
adoptedLegacyStarted.replay.status !== 'existing' ||
|
||||
adoptedLegacyStarted.replay.state !== 'manual_required' ||
|
||||
adoptedLegacyStarted.consumed.state !== 'manual_required' ||
|
||||
adoptedLegacyStarted.outcome.mutationDisposition !== 'replay-inspected' ||
|
||||
adoptedLegacyStarted.outcome.manualReason !== 'manager_state_unproved' ||
|
||||
adoptedLegacyStarted.outcome.targetObservation.activeState !==
|
||||
'inactive' ||
|
||||
adoptedLegacyStarted.outcome.legacyObservation.activeState !== 'inactive'
|
||||
) {
|
||||
fail('legacy barrier replay did not fail closed without a second start');
|
||||
}
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
evidenceClass: 'qinglong3_service_manager_bridge_live_actor',
|
||||
managerKind: kind,
|
||||
identityMode,
|
||||
scenario,
|
||||
serviceUid: uid,
|
||||
manager: managerOptions,
|
||||
actions: [
|
||||
@@ -746,6 +1111,11 @@ async function main(argv) {
|
||||
stopped: adoptedStopped,
|
||||
rollbackPrepared: adoptedRollback.result,
|
||||
rollbackReplay: adoptedRollbackReplay,
|
||||
rollbackAuthorized: adoptedAuthorization.result,
|
||||
rollbackAuthorizationReplay: adoptedAuthorizationReplay,
|
||||
legacyStarted: adoptedLegacyStarted.result,
|
||||
legacyStartReplay: adoptedLegacyStarted.replay,
|
||||
legacyConsumed: adoptedLegacyStarted.consumed,
|
||||
},
|
||||
gates: {
|
||||
rootCommandFile: true,
|
||||
@@ -762,6 +1132,18 @@ async function main(argv) {
|
||||
adoptedRollbackPrepared: true,
|
||||
adoptedRollbackReplay: true,
|
||||
adoptedRollbackCandidate: true,
|
||||
adoptedRollbackAuthorized: true,
|
||||
adoptedRollbackAuthorizationReplay: true,
|
||||
adoptedLegacyStarted: scenario === 'success',
|
||||
adoptedLegacyStartReplay: true,
|
||||
adoptedLegacyConsumed: true,
|
||||
adoptedTargetRemainedStopped: true,
|
||||
adoptedLegacyProcessIdentity: scenario === 'success',
|
||||
adoptedLegacyResponseLossInspected: scenario === 'success',
|
||||
adoptedLegacyBarrierCrash: scenario === 'barrier-crash',
|
||||
adoptedLegacyInspectOnlyConvergence: scenario === 'barrier-crash',
|
||||
adoptedLegacyRemainedStopped: scenario === 'barrier-crash',
|
||||
adoptedLegacyManualConsumed: scenario === 'barrier-crash',
|
||||
},
|
||||
};
|
||||
process.stdout.write(
|
||||
|
||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 108,
|
||||
sourceFiles: 111,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 50,
|
||||
nestedSourceFiles: 107,
|
||||
nestedSourceFiles: 110,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user