feat(ql3): commit service manager legacy rollback

This commit is contained in:
whyour
2026-08-20 17:40:29 +08:00
parent ad291e514d
commit 2cd6ea1d55
16 changed files with 3382 additions and 62 deletions
+20
View File
@@ -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 barrierbarrier 后所有重放只能 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/passedworkspace 仍为 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 modulesAdopted 为 `2,817,964 / 2,818,087` bytes、58 loaded modules
Application+AI 为 `4,501,822 / 4,501,954` bytesMCP 为 `7,324,601 / 7,324,709` bytes。systemd live actor 已覆盖
root/non-root success、真实 manager start、response-loss inspect convergence、Owner consume/replayroot 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-0473Service Manager Legacy Rollback Commit
- 状态:Accepted
- 日期:2026-08-20
- 关联 RFCQL-RFC-0001 D-64、D-274、D-275、D-379、D-380
- 关联 ADRADR-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/逐包测试单次退出 0backend 全量
`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 modulesAdopted 为 `2,817,964 / 2,818,087` bytes、58 loaded modulesApplication+AI 为
`4,501,822 / 4,501,954` bytesMCP 为 `7,324,601 / 7,324,709` bytes。回滚 authority 未进入基础运行闭包。
- systemd live actor 已覆盖 root/non-root 成功、真实 manager start、响应丢失后的 inspect-only 收敛、Owner
`legacy_running` 消费与 exact replayroot barrier-crash 场景证明没有第二次 startlegacy/target 均保持 inactiveOwner 收敛为
`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 已全部完成。
+1
View File
@@ -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 | AcceptedOpenRC 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' ||
@@ -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,
);
}
@@ -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,
}),
);
}
@@ -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,
}),
});
}
@@ -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/,
);
});
+18 -9
View File
@@ -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,
+397 -15
View File
@@ -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(
+2 -2
View File
@@ -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' },
},
);