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