mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(local): fence reconciliation completion
This commit is contained in:
@@ -92,6 +92,23 @@
|
||||
`shallowSourcePackages=[]`;Local Owner 为 `169 source / 168 nested / 1 root binary entry`。D-393 仍未代表完整 reconciliation:Automation 之外的
|
||||
Secret、Plugin、Identity、history 等领域 adapter 尚未 apply,target service 也未获得 restart authority;下一切片是跨领域 completion fence,完成后才可进入
|
||||
target restart/readiness。
|
||||
- D-394/ADR-0488(已接受):跨领域 completion 不再由某个 adapter 状态或调用方自报列表推断。既有 Local Owner 在同一包内新增
|
||||
`deployment/reconciliation/completion/{contract,evidence,coordinator}` 与
|
||||
`reconciliation-complete|reconciliation-complete-verify`,重新打开 D-392 sealed application terminal 并按固定顺序推导全部八项证据。V1 只允许八域全
|
||||
`no_effect`,或仅 Automation 为 `adapter_required` 且 decision/apply/current target/head 完整一致、其余七域全 `no_effect`;任何 manual、其他 adapter、
|
||||
rolled-back 或 drift 都失败关闭。真实完整迁移库当前仍会在 Secret/Config、Run History、Identity/Policy/Audit、Unknown 等领域产生受保护的
|
||||
`manual_external`,因此 Automation applied 不会被冒充为全局完成,rollback backup 和 target-stop 状态均保持不变。
|
||||
|
||||
mutation 固定为 receipt no-replace publish → `0400/0500` seal → instance head CAS 到 `reconciliation_completed` → 可选 Automation backup collection;
|
||||
只有 completed head durable 后才删除数据库等量 backup,四个 crash/response-loss 窗口均可 exact replay。target runner 与 Service Manager 继续共享
|
||||
`assertLocalCutoverTargetHead`:application-planned、automation-applied、manual 和 rolled-back 都不能重启,只有 completed 可进入下一 generation 的
|
||||
`target_active`。Service Manager v1 intent 仍只有 prior service journal digest,没有独立 completion-head digest,因此 systemd/OpenRC 路径在后续
|
||||
compare-and-swap 中继续失败关闭;不得仅放宽共享 assertion 来伪装支持,后续必须以 v2 intent 同时绑定两条 lineage。verify 只读且不修复。实现未新增
|
||||
package、dependency、SQL、daemon、timer、watcher、listener、Pool 或 Cluster workload;workspace
|
||||
仍为 18 packages,Local Owner 为 `172 source / 171 nested / 1 root binary entry`。focused completion `2/2`、完整 Local Owner
|
||||
`268 total / 261 pass / 7 conditional skip / 0 fail`、18-package clean build/逐包测试、dependency/package boundary `70/70` 和 122-module Edge
|
||||
import audit 已通过;其余六个领域的 terminal adapter、Service Manager v2 binding、完整 target readiness/restart 演练和 Cluster 自有 completion
|
||||
authority 仍是后续门禁。
|
||||
- D-392/ADR-0485(已接受):D-391 的 signed review 不能直接获得通用 DML authority;表级 `adopt_legacy/retain_both` 也不能证明
|
||||
Automation 行级 command/trigger 兼容,更不能覆盖 Secret custody、append-only history、Plugin/AI 外部资产与 Identity/Policy 语义。
|
||||
因此既有 Local Owner 新增 `reconciliation.application.prepare|commit|verify`,以
|
||||
|
||||
@@ -96,4 +96,4 @@ Applied backup 在尚未 rollback 时不得自动删除。跨 Automation、Secre
|
||||
- 完整 Local Owner 在真实 loopback 环境 `266 total / 259 pass / 7 conditional skip / 0 fail`;18-package clean build/逐包测试退出 0;真实 stopped-target Docker reconciliation `2/2`。
|
||||
- cluster dependency 与 package boundary 组合门 `70/70`,workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 为 `169 source / 168 nested / 1 root binary entry`。
|
||||
- 不新增 workspace package、production dependency、daemon、timer、watcher、listener、SQL migration、PostgreSQL role/ACL、Pool 或 cluster workload。
|
||||
- D-393 仍未完成:下一切片必须建立跨领域 completion fence;Secret、Plugin、Identity、history 等 adapter 及 target restart/readiness authority 仍关闭。
|
||||
- 后续 ADR-0488 已建立跨领域 completion fence:八域全 `no_effect` 可进入 `reconciliation_completed` 并获得 target generation 2 重启 authority;Automation apply 只有在其余七域同样 `no_effect` 时才能完成并回收 backup。当前完整迁移库仍有 Secret、Identity、history 等 manual 领域时会失败关闭并继续保留 rollback authority。
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# ADR-0488:跨领域 Reconciliation 完成围栏与目标重启授权
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-22
|
||||
- 决策:D-394
|
||||
- 关联:ADR-0482、ADR-0485、ADR-0486、ADR-0487
|
||||
|
||||
## 背景
|
||||
|
||||
D-392 把 reconciliation 拆为固定八个领域,D-393 已实现首个 Automation plan、review、apply、verify 与 rollback。然而 instance lineage 在 Automation apply 后只停在 `reconciliation_automation_applied`,target start authority 又只接受早期 `legacy_stopped|target_active|manual_required`。系统因此同时存在两个缺口:
|
||||
|
||||
- 没有一个证据能证明八个领域都已终结,不能安全地把单领域 apply 当作整体完成;
|
||||
- 即使八个领域都明确选择 no-effect,也没有合法状态可进入 target generation 2。
|
||||
|
||||
直接允许 `reconciliation_automation_applied` 重启是不安全的。真实迁移后的目标 SQLite 仍可能在 Secret/Config、Run History、Identity/Policy/Audit 或 Unknown 领域包含 `manual_external`;Automation 写入成功不等于这些领域已经被处理。另一方面,永久拒绝 all-no-effect 场景会让完成了人工 review、明确保留 Target/排除 Legacy 的部署无法继续。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 完成是独立的跨领域状态,而不是某个 adapter 的别名
|
||||
|
||||
实例谱系新增唯一终态 `reconciliation_completed`。它只能从以下 source head 前进:
|
||||
|
||||
- `reconciliation_application_planned`:八个领域全部为 `no_effect`;
|
||||
- `reconciliation_automation_applied`:只有 Automation 为 `adapter_required`、其余七个领域全部为 `no_effect`,且 Automation decision、apply receipt、当前 target snapshot 与 head 完整一致。
|
||||
|
||||
任何 `manual_external|adapter_and_manual`、非 Automation 的 `adapter_required`、Automation rolled-back 或证据漂移都失败关闭。当前完整迁移数据库通常仍包含多个受保护的 manual 领域,因此 Automation apply 不会被误判为 restart-ready;backup 继续保留,等待后续领域 adapter 或显式 rollback。
|
||||
|
||||
### 2. 调用方不能自报八领域完成
|
||||
|
||||
`local.deployment.reconciliation.complete` 只接收 completion/application/head digest,以及可选的 Automation apply digest/identity。coordinator 重新打开已封存的 application terminal,并严格按 D-392 固定顺序从 plan 推导八项 domain evidence:
|
||||
|
||||
- `no_effect` 绑定 application domain `summaryDigest`;
|
||||
- Automation adapter 绑定真实 `applyDigest`。
|
||||
|
||||
receipt 固定包含八项、`adapterCount=0|1`、application plan、source head、实例 identity、generation、时间与自身 digest。调用方不能提交任意 domain 数组、路径、row body、Secret、reviewer 或 credential 作为完成声明。
|
||||
|
||||
### 3. Receipt-first、head-second、重资产最后回收
|
||||
|
||||
mutation 顺序固定为:
|
||||
|
||||
1. 重新验证 application、可选 Automation apply、当前 target snapshot 与 source head;
|
||||
2. no-replace 发布 completion receipt,并封存为 `0400`;目录封为 `0500`;
|
||||
3. CAS 前进到 `reconciliation_completed`;
|
||||
4. 仅在 completed head 已 durable 后回收 Automation rollback backup,并把空 `backup/`、`rollback-work/` 封为 `0500`。
|
||||
|
||||
receipt publish、seal、head advance 或 backup collection 任一响应丢失都可由相同 exact command 重放。顺序禁止在 head 仍是 applied 时删除 backup;若在 head 完成后、回收前崩溃,重放只继续收紧存储,不恢复 rollback authority。
|
||||
|
||||
`local.deployment.reconciliation.complete.verify` 全程只读:验证 sealed receipt、八领域推导、application/Automation binding、当前 target snapshot、completed head 与已经收敛的存储形态,不修复漂移。
|
||||
|
||||
### 4. 直接 Target 重启只接受完成围栏
|
||||
|
||||
Docker target authority 继续拒绝 `reconciliation_application_planned`、`reconciliation_automation_applied` 和所有 manual/rolled-back 中间态;只有 `reconciliation_completed` 能重新进入 `target_active`,且调用方必须使用下一 generation。
|
||||
|
||||
Service Manager 虽然复用同一个 head assertion,但既有 v1 intent 只绑定 prior service journal `previousRecordDigest`,没有独立的 expected completion-head digest;其后续 compare-and-swap 因而继续拒绝 completed head。D-394 不通过忽略该比较来伪造兼容性。systemd/OpenRC 的 restart-ready 必须由后续 v2 intent 同时绑定 prior service record 与 exact completion head 后才能开放。
|
||||
|
||||
### 5. 部署规模与代码边界
|
||||
|
||||
实现位于既有 `@qinglong/local-owner-cli` 的 `deployment/reconciliation/completion/` 子域,按 contract/evidence/coordinator 三个职责文件组织;没有新拆 workspace package,也没有把实现平铺到 `src/` 根。
|
||||
|
||||
- Edge/Standalone:无 daemon、timer、watcher、listener、Pool 或常驻缓存;receipt 上限 64 KiB、completion catalog 上限 64,SQLite snapshot/hash 复用固定内存实现;completed 后同步释放数据库等量 rollback backup。
|
||||
- Cluster:本 ADR 只定义 Local SQLite authority。PostgreSQL/多副本必须使用独立的事务、snapshot 与 HA evidence,不能复用本机文件路径或把 Local receipt 当成 Cluster completion。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### Automation applied 直接允许重启
|
||||
|
||||
拒绝。它会跳过其余七个领域,尤其会把 Secret custody、append-only history 和 Identity/Policy manual decision 静默吞掉。
|
||||
|
||||
### 调用方提交 `completedDomains[]`
|
||||
|
||||
拒绝。自由数组无法证明来源、顺序、摘要和 current head,容易把 stale 或伪造 claim 变成 restart authority。
|
||||
|
||||
### 先删除 backup 再写 completed head
|
||||
|
||||
拒绝。head CAS 失败会留下 applied 状态却失去 rollback source,形成不可恢复的部分提交。
|
||||
|
||||
### 为完成围栏新增独立 package/service/GC timer
|
||||
|
||||
拒绝。该职责没有独立部署和扩缩容边界;额外 package、进程或周期扫描只会增加路由设备的安装元数据、RSS、唤醒和竞态。
|
||||
|
||||
## 验收证据
|
||||
|
||||
- completion focused `2/2`:覆盖 all-no-effect 的 receipt/seal/head 三个 response-loss 窗口、只读 verify、CLI content-free、直接 target generation 2 状态转换;同时覆盖 Automation 已 apply 但其他领域仍 manual 时拒绝完成、拒绝 target restart,并保持 `0400/0500` backup authority。
|
||||
- 完整 Local Owner `268 total / 261 pass / 7 conditional skip / 0 fail`;18-package clean build 与逐包测试 exit 0。
|
||||
- dependency/package boundary 组合门 `70/70`,Edge import audit 为 122 modules、0 forbidden;workspace 保持 18 packages、无 single-source/shallow package;Local Owner 为 `172 source / 171 nested / 1 root binary entry`。
|
||||
- 不新增 production dependency、SQL migration、daemon、timer、watcher、listener、Pool、PostgreSQL role/ACL 或 cluster workload。
|
||||
- Secret/Config、Run History、Plugin Package、AI/Tool、Identity/Policy/Audit、Unknown 的 terminal adapter,以及 Service Manager v2 completion-head binding/真实 restart 演练仍是后续工作;D-394 不把尚未实现的领域或部署方式宣称为完成。
|
||||
@@ -37,6 +37,7 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_automation_apply_prepared'
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'reconciliation_completed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -182,6 +183,7 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_automation_apply_prepared' &&
|
||||
head.state !== 'reconciliation_automation_applied' &&
|
||||
head.state !== 'reconciliation_automation_rolled_back' &&
|
||||
head.state !== 'reconciliation_completed' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
@@ -362,6 +364,7 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_automation_apply_prepared'
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'reconciliation_completed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -414,6 +417,7 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_automation_apply_prepared' ||
|
||||
current.state === 'reconciliation_automation_applied' ||
|
||||
current.state === 'reconciliation_automation_rolled_back' ||
|
||||
current.state === 'reconciliation_completed' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running' ||
|
||||
current.state === 'legacy_ready')
|
||||
@@ -427,7 +431,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
(state === 'legacy_stopped' && current.state === 'legacy_stop_requested') ||
|
||||
(state === 'target_active' &&
|
||||
(current.state === 'legacy_stopped' ||
|
||||
current.state === 'target_active')) ||
|
||||
current.state === 'target_active' ||
|
||||
current.state === 'reconciliation_completed')) ||
|
||||
(state === 'target_stopped' && current.state === 'target_active') ||
|
||||
(state === 'reconciliation_capture_prepared' &&
|
||||
current.state === 'target_stopped') ||
|
||||
@@ -457,6 +462,9 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_automation_apply_prepared') ||
|
||||
(state === 'reconciliation_automation_rolled_back' &&
|
||||
current.state === 'reconciliation_automation_applied') ||
|
||||
(state === 'reconciliation_completed' &&
|
||||
(current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'reconciliation_automation_applied')) ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
@@ -497,6 +505,7 @@ export function assertLocalCutoverTargetHead(
|
||||
head.activationDigest !== identity.request.expectedActivationDigest ||
|
||||
(head.state !== 'legacy_stopped' &&
|
||||
head.state !== 'target_active' &&
|
||||
head.state !== 'reconciliation_completed' &&
|
||||
head.state !== 'manual_required')
|
||||
) {
|
||||
configurationError(
|
||||
|
||||
@@ -154,6 +154,12 @@ import {
|
||||
verifyLocalReconciliationAutomationApply,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
} from './reconciliation/application/automation/applyCoordinator';
|
||||
import {
|
||||
completeLocalReconciliation,
|
||||
completeLocalReconciliationCommandFile,
|
||||
verifyLocalReconciliationCompletion,
|
||||
verifyLocalReconciliationCompletionCommandFile,
|
||||
} from './reconciliation/completion/coordinator';
|
||||
|
||||
export {
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -193,8 +199,29 @@ export {
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
rollbackLocalReconciliationAutomationApply,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
completeLocalReconciliation,
|
||||
completeLocalReconciliationCommandFile,
|
||||
verifyLocalReconciliationCompletion,
|
||||
verifyLocalReconciliationCompletionCommandFile,
|
||||
};
|
||||
|
||||
export {
|
||||
normalizeLocalReconciliationCompleteCommand,
|
||||
normalizeLocalReconciliationCompletionVerifyCommand,
|
||||
type LocalReconciliationCompleteCommand,
|
||||
type LocalReconciliationCompletionAutomationBinding,
|
||||
type LocalReconciliationCompletionAutomationOptions,
|
||||
type LocalReconciliationCompletionOptions,
|
||||
type LocalReconciliationCompletionResult,
|
||||
type LocalReconciliationCompletionVerifyCommand,
|
||||
} from './reconciliation/completion/contract';
|
||||
export { type LocalReconciliationCompletionDependencies } from './reconciliation/completion/coordinator';
|
||||
export {
|
||||
normalizeLocalReconciliationCompletionReceipt,
|
||||
type LocalReconciliationCompletionDomainEvidence,
|
||||
type LocalReconciliationCompletionReceipt,
|
||||
} from './reconciliation/completion/evidence';
|
||||
|
||||
export {
|
||||
normalizeLocalReconciliationAutomationApplyCommand,
|
||||
normalizeLocalReconciliationAutomationApplyRollbackCommand,
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
applyLocalReconciliationAutomationCommandFile,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
completeLocalReconciliationCommandFile,
|
||||
verifyLocalReconciliationCompletionCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
@@ -48,7 +50,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|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-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|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|adopted-prepare|adopted-verify|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-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-complete|reconciliation-complete-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -98,6 +100,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-automation-apply' &&
|
||||
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
||||
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
||||
argv[0] !== 'reconciliation-complete' &&
|
||||
argv[0] !== 'reconciliation-complete-verify' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
argv[0] !== 'compose-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
@@ -210,6 +214,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-apply-rollback'
|
||||
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-complete'
|
||||
? completeLocalReconciliationCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-complete-verify'
|
||||
? verifyLocalReconciliationCompletionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-revision'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-preflight'
|
||||
|
||||
+49
@@ -503,6 +503,55 @@ export function validateLocalReconciliationAutomationAppliedStorage(
|
||||
validateBackup(selected, intent, uid, [0o400]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the rollback copy only after the cross-domain completion head is
|
||||
* durable. The operation accepts its own partially collected layout so a
|
||||
* response loss or process crash can be replayed without restoring authority.
|
||||
*/
|
||||
export function collectLocalReconciliationAutomationCompletedStorage(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o700, 0o500], 'backup root');
|
||||
directoryMode(
|
||||
selected.rollbackRoot,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'rollback work root',
|
||||
);
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
emptyDirectory(selected.rollbackRoot, 'rollback work root');
|
||||
stableJson(selected.intent, uid, [0o400], 'intent', [1n]);
|
||||
stableJson(selected.receipt, uid, [0o400], 'receipt', [1n]);
|
||||
if (fs.existsSync(selected.backup)) {
|
||||
validateBackup(selected, intent, uid, [0o400]);
|
||||
if ((fs.statSync(selected.backupRoot).mode & 0o777) !== 0o700) {
|
||||
fs.chmodSync(selected.backupRoot, 0o700);
|
||||
syncDirectory(selected.root);
|
||||
}
|
||||
unlinkIfPresent(selected.backup);
|
||||
}
|
||||
sealDirectory(selected.backupRoot, uid, 'backup root');
|
||||
sealDirectory(selected.rollbackRoot, uid, 'rollback work root');
|
||||
validateLocalReconciliationAutomationCompletedStorage(selected, uid);
|
||||
}
|
||||
|
||||
export function validateLocalReconciliationAutomationCompletedStorage(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||
directoryMode(selected.rollbackRoot, uid, [0o500], 'rollback work root');
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
emptyDirectory(selected.backupRoot, 'backup root');
|
||||
emptyDirectory(selected.rollbackRoot, 'rollback work root');
|
||||
stableJson(selected.intent, uid, [0o400], 'intent', [1n]);
|
||||
stableJson(selected.receipt, uid, [0o400], 'receipt', [1n]);
|
||||
}
|
||||
|
||||
export function prepareLocalReconciliationAutomationRollbackSource(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { currentIdentity } from '../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const UUID_V7 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SAFE_PATH = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
|
||||
export interface LocalReconciliationCompletionAutomationOptions {
|
||||
readonly automationRoot: string;
|
||||
readonly automationDecisionRoot: string;
|
||||
readonly automationApplyRoot: string;
|
||||
readonly targetDatabasePath: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly completionRoot: string;
|
||||
readonly automation: Readonly<LocalReconciliationCompletionAutomationOptions> | null;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionAutomationBinding {
|
||||
readonly automationId: string;
|
||||
readonly decisionId: string;
|
||||
readonly expectedApplyDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompleteCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.complete';
|
||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||
readonly request: Readonly<{
|
||||
completionId: string;
|
||||
applicationId: string;
|
||||
expectedApplicationPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||
completedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.complete.verify';
|
||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||
readonly request: Readonly<{
|
||||
completionId: string;
|
||||
applicationId: string;
|
||||
expectedCompletionDigest: string;
|
||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| LocalReconciliationCompleteCommand['operation']
|
||||
| LocalReconciliationCompletionVerifyCommand['operation'];
|
||||
readonly status: 'completed' | 'existing' | 'verified';
|
||||
readonly state: 'reconciliation_completed';
|
||||
readonly completionId: string;
|
||||
readonly applicationId: string;
|
||||
readonly completionDigest: string;
|
||||
readonly domainCount: 8;
|
||||
readonly adapterCount: 0 | 1;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation completion ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
fail(`${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])
|
||||
) {
|
||||
fail(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > 4_096
|
||||
) {
|
||||
fail(`${label} must be a safe non-root absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function overlaps(left: string, right: string): boolean {
|
||||
const relative = path.relative(left, right);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function identifier(value: unknown, pattern: RegExp, label: string): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
fail(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
return identifier(value, DIGEST, label);
|
||||
}
|
||||
|
||||
function normalizeAutomationOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionAutomationOptions> | null {
|
||||
if (value === null) return null;
|
||||
const selected = record(value, 'automation options');
|
||||
exact(
|
||||
selected,
|
||||
[
|
||||
'automationApplyRoot',
|
||||
'automationDecisionRoot',
|
||||
'automationRoot',
|
||||
'targetDatabasePath',
|
||||
],
|
||||
'automation options',
|
||||
);
|
||||
return Object.freeze({
|
||||
automationRoot: safePath(selected.automationRoot, 'automationRoot'),
|
||||
automationDecisionRoot: safePath(
|
||||
selected.automationDecisionRoot,
|
||||
'automationDecisionRoot',
|
||||
),
|
||||
automationApplyRoot: safePath(
|
||||
selected.automationApplyRoot,
|
||||
'automationApplyRoot',
|
||||
),
|
||||
targetDatabasePath: safePath(
|
||||
selected.targetDatabasePath,
|
||||
'targetDatabasePath',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionOptions> {
|
||||
const selected = record(value, 'options');
|
||||
exact(
|
||||
selected,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'automation',
|
||||
'completionRoot',
|
||||
'deploymentRoot',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
if (
|
||||
typeof selected.allowRootService !== 'boolean' ||
|
||||
(currentIdentity().uid === 0) !== selected.allowRootService
|
||||
) {
|
||||
fail('command identity is invalid');
|
||||
}
|
||||
const automation = normalizeAutomationOptions(selected.automation);
|
||||
const normalized = Object.freeze({
|
||||
deploymentRoot: safePath(selected.deploymentRoot, 'deploymentRoot'),
|
||||
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
|
||||
completionRoot: safePath(selected.completionRoot, 'completionRoot'),
|
||||
automation,
|
||||
allowRootService: selected.allowRootService,
|
||||
}) as Readonly<LocalReconciliationCompletionOptions>;
|
||||
const roots = [
|
||||
normalized.deploymentRoot,
|
||||
normalized.applicationRoot,
|
||||
normalized.completionRoot,
|
||||
...(automation === null
|
||||
? []
|
||||
: [
|
||||
automation.automationRoot,
|
||||
automation.automationDecisionRoot,
|
||||
automation.automationApplyRoot,
|
||||
]),
|
||||
];
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
if (
|
||||
overlaps(roots[left]!, roots[right]!) ||
|
||||
overlaps(roots[right]!, roots[left]!)
|
||||
) {
|
||||
fail('authority roots overlap');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
automation !== null &&
|
||||
roots.some(
|
||||
(root) =>
|
||||
overlaps(root, automation.targetDatabasePath) ||
|
||||
overlaps(automation.targetDatabasePath, root),
|
||||
)
|
||||
) {
|
||||
fail('targetDatabasePath overlaps an authority root');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeAutomationBinding(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionAutomationBinding> | null {
|
||||
if (value === null) return null;
|
||||
const selected = record(value, 'automation binding');
|
||||
exact(
|
||||
selected,
|
||||
['automationId', 'decisionId', 'expectedApplyDigest'],
|
||||
'automation binding',
|
||||
);
|
||||
return Object.freeze({
|
||||
automationId: identifier(selected.automationId, UUID_V4, 'automationId'),
|
||||
decisionId: identifier(selected.decisionId, UUID_V7, 'decisionId'),
|
||||
expectedApplyDigest: digest(
|
||||
selected.expectedApplyDigest,
|
||||
'expectedApplyDigest',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function command(value: unknown, operation: string) {
|
||||
const selected = record(value, 'command');
|
||||
exact(
|
||||
selected,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||
fail('command version or operation is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: normalizeOptions(selected.options),
|
||||
request: record(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationCompleteCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompleteCommand> {
|
||||
const selected = command(value, 'local.deployment.reconciliation.complete');
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completedAtMs',
|
||||
'completionId',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.completedAtMs) ||
|
||||
(selected.request.completedAtMs as number) < 0
|
||||
) {
|
||||
fail('completedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
completionId: identifier(
|
||||
selected.request.completionId,
|
||||
UUID_V4,
|
||||
'completionId',
|
||||
),
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
UUID_V4,
|
||||
'applicationId',
|
||||
),
|
||||
expectedApplicationPlanDigest: digest(
|
||||
selected.request.expectedApplicationPlanDigest,
|
||||
'expectedApplicationPlanDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
automation: normalizeAutomationBinding(selected.request.automation),
|
||||
completedAtMs: selected.request.completedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationCompletionVerifyCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionVerifyCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.complete.verify',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
['applicationId', 'automation', 'completionId', 'expectedCompletionDigest'],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.complete.verify',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
completionId: identifier(
|
||||
selected.request.completionId,
|
||||
UUID_V4,
|
||||
'completionId',
|
||||
),
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
UUID_V4,
|
||||
'applicationId',
|
||||
),
|
||||
expectedCompletionDigest: digest(
|
||||
selected.request.expectedCompletionDigest,
|
||||
'expectedCompletionDigest',
|
||||
),
|
||||
automation: normalizeAutomationBinding(selected.request.automation),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import { inspectLocalSqliteSnapshot } from '@qinglong/local-sqlite/rollout-safety';
|
||||
|
||||
import { currentIdentity } from '../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
} from '../../foundation/files';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
type LocalCutoverInstanceHead,
|
||||
} from '../../cutover/instanceLineage';
|
||||
import { readLocalReconciliationApplicationTerminal } from '../application/coordinator';
|
||||
import type { LocalReconciliationApplicationTerminal } from '../application/coordinator';
|
||||
import {
|
||||
collectLocalReconciliationAutomationCompletedStorage,
|
||||
localReconciliationAutomationApplyPaths,
|
||||
readLocalReconciliationAutomationApplyIntent,
|
||||
readLocalReconciliationAutomationApplyReceipt,
|
||||
validateLocalReconciliationAutomationAppliedStorage,
|
||||
validateLocalReconciliationAutomationApplyCatalog,
|
||||
validateLocalReconciliationAutomationApplyLayout,
|
||||
validateLocalReconciliationAutomationCompletedStorage,
|
||||
} from '../application/automation/applyStorage';
|
||||
import type {
|
||||
LocalReconciliationAutomationApplyIntent,
|
||||
LocalReconciliationAutomationApplyReceipt,
|
||||
} from '../application/automation/applyEvidence';
|
||||
import { readLocalReconciliationAutomationDecisionTerminal } from '../application/automation/decisionCoordinator';
|
||||
import type { LocalReconciliationCompletionDomainEvidence } from './evidence';
|
||||
import {
|
||||
buildLocalReconciliationCompletionReceipt,
|
||||
localReconciliationCompletionReceiptContents,
|
||||
normalizeLocalReconciliationCompletionReceipt,
|
||||
type LocalReconciliationCompletionReceipt,
|
||||
} from './evidence';
|
||||
import {
|
||||
normalizeLocalReconciliationCompleteCommand,
|
||||
normalizeLocalReconciliationCompletionVerifyCommand,
|
||||
type LocalReconciliationCompleteCommand,
|
||||
type LocalReconciliationCompletionOptions,
|
||||
type LocalReconciliationCompletionResult,
|
||||
type LocalReconciliationCompletionVerifyCommand,
|
||||
} from './contract';
|
||||
|
||||
const MAX_COMPLETIONS = 64;
|
||||
const MAX_RECEIPT_BYTES = 64 * 1024;
|
||||
|
||||
interface CompletionPaths {
|
||||
readonly root: string;
|
||||
readonly receipt: string;
|
||||
}
|
||||
|
||||
interface AutomationProof {
|
||||
readonly intent: Readonly<LocalReconciliationAutomationApplyIntent>;
|
||||
readonly receipt: Readonly<LocalReconciliationAutomationApplyReceipt>;
|
||||
readonly paths: ReturnType<typeof localReconciliationAutomationApplyPaths>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionDependencies {
|
||||
readonly inspectSnapshot?: typeof inspectLocalSqliteSnapshot;
|
||||
readonly afterReceiptPublished?: () => void;
|
||||
readonly afterTerminalSealed?: () => void;
|
||||
readonly afterHeadAdvanced?: () => void;
|
||||
readonly afterBackupCollected?: () => void;
|
||||
}
|
||||
|
||||
function fail(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation completion ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
function completionPaths(
|
||||
completionRoot: string,
|
||||
completionId: string,
|
||||
): Readonly<CompletionPaths> {
|
||||
const root = path.join(completionRoot, completionId);
|
||||
return Object.freeze({ root, receipt: path.join(root, 'receipt.json') });
|
||||
}
|
||||
|
||||
function validateDirectory(
|
||||
directory: string,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
label: string,
|
||||
): number {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
return fail(`${label} is unavailable`, error);
|
||||
}
|
||||
const mode = stat.mode & 0o777;
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
!modes.includes(mode) ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
fail(`${label} identity is invalid`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
function validateCatalog(selected: Readonly<CompletionPaths>, sealed: boolean) {
|
||||
const allowed = new Set([
|
||||
'receipt.json',
|
||||
...(!sealed ? ['.receipt.json.ql3-deploy-stage'] : []),
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
fail('receipt catalog contains unknown material');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCompletionDirectory(
|
||||
completionRoot: string,
|
||||
completionId: string,
|
||||
uid: number,
|
||||
): Readonly<CompletionPaths> {
|
||||
const selected = completionPaths(completionRoot, completionId);
|
||||
const entries = fs.readdirSync(completionRoot, { withFileTypes: true });
|
||||
if (entries.some((entry) => !entry.isDirectory() || entry.isSymbolicLink())) {
|
||||
fail('completion catalog contains drift');
|
||||
}
|
||||
if (entries.length >= MAX_COMPLETIONS && !fs.existsSync(selected.root)) {
|
||||
fail('completion retention limit is reached');
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(selected.root, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
fail('receipt directory cannot be created', error);
|
||||
}
|
||||
}
|
||||
validateDirectory(selected.root, uid, [0o700, 0o500], 'receipt directory');
|
||||
validateCatalog(
|
||||
selected,
|
||||
(fs.statSync(selected.root).mode & 0o777) === 0o500,
|
||||
);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function stableReceipt(
|
||||
selected: Readonly<CompletionPaths>,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
): Readonly<LocalReconciliationCompletionReceipt> {
|
||||
let descriptor: number | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(selected.receipt, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
!modes.includes(Number(before.mode) & 0o777) ||
|
||||
before.nlink !== 1n ||
|
||||
before.size < 2n ||
|
||||
before.size > BigInt(MAX_RECEIPT_BYTES)
|
||||
) {
|
||||
fail('receipt identity is invalid');
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
selected.receipt,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs ||
|
||||
opened.mode !== before.mode ||
|
||||
opened.uid !== before.uid ||
|
||||
opened.nlink !== before.nlink
|
||||
) {
|
||||
fail('receipt changed while opening');
|
||||
}
|
||||
bytes = Buffer.alloc(Number(opened.size));
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const count = fs.readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (count < 1) fail('receipt read stalled');
|
||||
offset += count;
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const pathAfter = fs.lstatSync(selected.receipt, { bigint: true });
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs ||
|
||||
after.mode !== opened.mode ||
|
||||
after.uid !== opened.uid ||
|
||||
after.nlink !== opened.nlink ||
|
||||
pathAfter.dev !== before.dev ||
|
||||
pathAfter.ino !== before.ino ||
|
||||
pathAfter.size !== before.size ||
|
||||
pathAfter.mtimeNs !== before.mtimeNs ||
|
||||
pathAfter.ctimeNs !== before.ctimeNs ||
|
||||
pathAfter.mode !== before.mode ||
|
||||
pathAfter.uid !== before.uid ||
|
||||
pathAfter.nlink !== before.nlink
|
||||
) {
|
||||
fail('receipt drifted while reading');
|
||||
}
|
||||
return normalizeLocalReconciliationCompletionReceipt(
|
||||
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return fail('receipt cannot be read', error);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function sealCompletion(
|
||||
selected: Readonly<CompletionPaths>,
|
||||
uid: number,
|
||||
): void {
|
||||
validateDirectory(selected.root, uid, [0o700, 0o500], 'receipt directory');
|
||||
stableReceipt(selected, uid, [0o600, 0o400]);
|
||||
let descriptor = fs.openSync(
|
||||
selected.receipt,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor);
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.uid !== uid ||
|
||||
opened.nlink !== 1 ||
|
||||
![0o600, 0o400].includes(opened.mode & 0o777)
|
||||
) {
|
||||
fail('receipt cannot be sealed');
|
||||
}
|
||||
if ((opened.mode & 0o777) !== 0o400) {
|
||||
fs.fchmodSync(descriptor, 0o400);
|
||||
fs.fsyncSync(descriptor);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
const rootMode = validateDirectory(
|
||||
selected.root,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'receipt directory',
|
||||
);
|
||||
descriptor = fs.openSync(selected.root, fs.constants.O_RDONLY);
|
||||
try {
|
||||
if (rootMode !== 0o500) fs.fchmodSync(descriptor, 0o500);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
validateCatalog(selected, true);
|
||||
stableReceipt(selected, uid, [0o400]);
|
||||
}
|
||||
|
||||
function validateApplication(
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
applicationId: string,
|
||||
applicationPlanDigest: string,
|
||||
deploymentRoot: string,
|
||||
applicationRoot: string,
|
||||
): void {
|
||||
if (
|
||||
terminal.intent.command.request.applicationId !== applicationId ||
|
||||
terminal.plan.applicationId !== applicationId ||
|
||||
terminal.plan.applicationPlanDigest !== applicationPlanDigest ||
|
||||
terminal.intent.command.options.deploymentRoot !== deploymentRoot ||
|
||||
terminal.intent.command.options.applicationRoot !== applicationRoot
|
||||
) {
|
||||
fail('application authority is detached');
|
||||
}
|
||||
}
|
||||
|
||||
async function automationProof(
|
||||
command: Readonly<LocalReconciliationCompleteCommand>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
uid: number,
|
||||
dependencies: LocalReconciliationCompletionDependencies,
|
||||
): Promise<Readonly<AutomationProof> | null> {
|
||||
const automationDomain = terminal.plan.domains.find(
|
||||
(domain) => domain.domain === 'automation',
|
||||
);
|
||||
if (!automationDomain) fail('automation domain is absent');
|
||||
if (automationDomain.action === 'no_effect') {
|
||||
if (
|
||||
command.options.automation !== null ||
|
||||
command.request.automation !== null
|
||||
) {
|
||||
fail('no-effect completion must not carry automation authority');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
automationDomain.action !== 'adapter_required' ||
|
||||
command.options.automation === null ||
|
||||
command.request.automation === null
|
||||
) {
|
||||
fail('automation domain is not terminally provable');
|
||||
}
|
||||
const options = command.options.automation;
|
||||
const binding = command.request.automation;
|
||||
for (const [directory, label] of [
|
||||
[options.automationRoot, 'automationRoot'],
|
||||
[options.automationDecisionRoot, 'automationDecisionRoot'],
|
||||
[options.automationApplyRoot, 'automationApplyRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
}
|
||||
const decision = await readLocalReconciliationAutomationDecisionTerminal(
|
||||
{
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
applicationRoot: command.options.applicationRoot,
|
||||
automationRoot: options.automationRoot,
|
||||
automationDecisionRoot: options.automationDecisionRoot,
|
||||
allowRootService: command.options.allowRootService,
|
||||
},
|
||||
binding.automationId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
decision.receipt.decisionId !== binding.decisionId ||
|
||||
decision.context.application.plan.applicationPlanDigest !==
|
||||
terminal.plan.applicationPlanDigest
|
||||
) {
|
||||
fail('automation decision is detached from application authority');
|
||||
}
|
||||
const selected = localReconciliationAutomationApplyPaths(
|
||||
options.automationApplyRoot,
|
||||
binding.automationId,
|
||||
);
|
||||
validateLocalReconciliationAutomationApplyLayout(selected, uid);
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
const intent = readLocalReconciliationAutomationApplyIntent(selected, uid);
|
||||
const receipt = readLocalReconciliationAutomationApplyReceipt(selected, uid);
|
||||
if (
|
||||
intent.command.options.deploymentRoot !== command.options.deploymentRoot ||
|
||||
intent.command.options.applicationRoot !==
|
||||
command.options.applicationRoot ||
|
||||
intent.command.options.automationRoot !== options.automationRoot ||
|
||||
intent.command.options.automationDecisionRoot !==
|
||||
options.automationDecisionRoot ||
|
||||
intent.command.options.automationApplyRoot !==
|
||||
options.automationApplyRoot ||
|
||||
intent.command.options.targetDatabasePath !== options.targetDatabasePath ||
|
||||
intent.command.request.automationId !== binding.automationId ||
|
||||
intent.command.request.decisionId !== binding.decisionId ||
|
||||
receipt.automationId !== binding.automationId ||
|
||||
receipt.decisionId !== binding.decisionId ||
|
||||
receipt.applyDigest !== binding.expectedApplyDigest ||
|
||||
receipt.preparationDigest !== intent.preparationDigest
|
||||
) {
|
||||
fail('automation apply evidence is detached');
|
||||
}
|
||||
const current = await (
|
||||
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||
)({
|
||||
databasePath: options.targetDatabasePath,
|
||||
profile: intent.profile,
|
||||
});
|
||||
if (current.sha256 !== receipt.targetAfter.sha256) {
|
||||
fail('automation target drifted after apply');
|
||||
}
|
||||
return Object.freeze({ intent, receipt, paths: selected });
|
||||
}
|
||||
|
||||
function domainEvidence(
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
automation: Readonly<AutomationProof> | null,
|
||||
): readonly Readonly<LocalReconciliationCompletionDomainEvidence>[] {
|
||||
return Object.freeze(
|
||||
terminal.plan.domains.map((domain) => {
|
||||
if (domain.action === 'no_effect') {
|
||||
return Object.freeze({
|
||||
domain: domain.domain,
|
||||
action: 'no_effect' as const,
|
||||
evidenceKind: 'application_summary' as const,
|
||||
evidenceDigest: domain.summaryDigest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
domain.domain === 'automation' &&
|
||||
domain.action === 'adapter_required' &&
|
||||
automation !== null
|
||||
) {
|
||||
return Object.freeze({
|
||||
domain: domain.domain,
|
||||
action: 'adapter_required' as const,
|
||||
evidenceKind: 'automation_apply' as const,
|
||||
evidenceDigest: automation.receipt.applyDigest,
|
||||
});
|
||||
}
|
||||
return fail(`${domain.domain} is not terminally reconciled`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function validateReceiptBinding(
|
||||
receipt: Readonly<LocalReconciliationCompletionReceipt>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
expectedDomains: readonly Readonly<LocalReconciliationCompletionDomainEvidence>[],
|
||||
completionId: string,
|
||||
applicationId: string,
|
||||
): void {
|
||||
if (
|
||||
receipt.completionId !== completionId ||
|
||||
receipt.applicationId !== applicationId ||
|
||||
receipt.profile !== terminal.intent.profile ||
|
||||
receipt.instanceId !== terminal.intent.instanceId ||
|
||||
receipt.cutoverId !== terminal.intent.cutoverId ||
|
||||
receipt.generation !== terminal.intent.generation ||
|
||||
receipt.activationDigest !== terminal.intent.activationDigest ||
|
||||
receipt.applicationPlanDigest !== terminal.plan.applicationPlanDigest ||
|
||||
JSON.stringify(receipt.domains) !== JSON.stringify(expectedDomains)
|
||||
) {
|
||||
fail('receipt is detached from terminal domain evidence');
|
||||
}
|
||||
}
|
||||
|
||||
function advanceCompletedHead(
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
receipt: Readonly<LocalReconciliationCompletionReceipt>,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
return advanceLocalCutoverInstanceHead(
|
||||
{
|
||||
options: {
|
||||
deploymentRoot: terminal.intent.command.options.deploymentRoot,
|
||||
},
|
||||
request: {
|
||||
cutoverId: terminal.intent.cutoverId,
|
||||
profile: terminal.intent.profile,
|
||||
instanceId: terminal.intent.instanceId,
|
||||
expectedActivationDigest: terminal.intent.activationDigest,
|
||||
requestedAtMs: receipt.completedAtMs,
|
||||
},
|
||||
},
|
||||
uid,
|
||||
'reconciliation_completed',
|
||||
terminal.intent.generation,
|
||||
receipt.completionDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: LocalReconciliationCompletionResult['operation'],
|
||||
status: LocalReconciliationCompletionResult['status'],
|
||||
receipt: Readonly<LocalReconciliationCompletionReceipt>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
): Readonly<LocalReconciliationCompletionResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
status,
|
||||
state: 'reconciliation_completed',
|
||||
completionId: receipt.completionId,
|
||||
applicationId: receipt.applicationId,
|
||||
completionDigest: receipt.completionDigest,
|
||||
domainCount: 8,
|
||||
adapterCount: receipt.adapterCount,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function assertSourceHead(
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
expectedHeadDigest: string,
|
||||
automation: Readonly<AutomationProof> | null,
|
||||
): void {
|
||||
const expectedState =
|
||||
automation === null
|
||||
? 'reconciliation_application_planned'
|
||||
: 'reconciliation_automation_applied';
|
||||
const expectedSource =
|
||||
automation === null ? undefined : automation.receipt.applyDigest;
|
||||
if (
|
||||
head.headDigest !== expectedHeadDigest ||
|
||||
head.state !== expectedState ||
|
||||
(expectedSource !== undefined && head.sourceRecordDigest !== expectedSource)
|
||||
) {
|
||||
fail('completion lost source head compare-and-swap');
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeLocalReconciliation(
|
||||
value: unknown,
|
||||
dependencies: LocalReconciliationCompletionDependencies = {},
|
||||
): Promise<Readonly<LocalReconciliationCompletionResult>> {
|
||||
const command = normalizeLocalReconciliationCompleteCommand(value);
|
||||
const uid = currentIdentity().uid;
|
||||
for (const [directory, label] of [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[command.options.applicationRoot, 'applicationRoot'],
|
||||
[command.options.completionRoot, 'completionRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
}
|
||||
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||
command.options.applicationRoot,
|
||||
command.request.applicationId,
|
||||
uid,
|
||||
);
|
||||
validateApplication(
|
||||
terminal,
|
||||
command.request.applicationId,
|
||||
command.request.expectedApplicationPlanDigest,
|
||||
command.options.deploymentRoot,
|
||||
command.options.applicationRoot,
|
||||
);
|
||||
const automation = await automationProof(
|
||||
command,
|
||||
terminal,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation);
|
||||
const selected = ensureCompletionDirectory(
|
||||
command.options.completionRoot,
|
||||
command.request.completionId,
|
||||
uid,
|
||||
);
|
||||
let receipt: Readonly<LocalReconciliationCompletionReceipt>;
|
||||
let status: 'completed' | 'existing' = 'completed';
|
||||
let head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
terminal.intent.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (fs.existsSync(selected.receipt)) {
|
||||
status = 'existing';
|
||||
receipt = stableReceipt(selected, uid, [0o600, 0o400]);
|
||||
validateReceiptBinding(
|
||||
receipt,
|
||||
terminal,
|
||||
domains,
|
||||
command.request.completionId,
|
||||
command.request.applicationId,
|
||||
);
|
||||
if (
|
||||
receipt.sourceHeadDigest !== command.request.expectedHeadDigest ||
|
||||
receipt.completedAtMs !== command.request.completedAtMs
|
||||
) {
|
||||
fail('completion command is not an exact replay');
|
||||
}
|
||||
} else {
|
||||
assertSourceHead(head, command.request.expectedHeadDigest, automation);
|
||||
const adapterCount = domains.filter(
|
||||
(domain) => domain.action === 'adapter_required',
|
||||
).length as 0 | 1;
|
||||
const latestEvidenceAtMs = Math.max(
|
||||
terminal.plan.committedAtMs,
|
||||
automation?.receipt.appliedAtMs ?? 0,
|
||||
);
|
||||
if (command.request.completedAtMs < latestEvidenceAtMs) {
|
||||
fail('completion timestamp precedes terminal evidence');
|
||||
}
|
||||
receipt = buildLocalReconciliationCompletionReceipt({
|
||||
completionId: command.request.completionId,
|
||||
applicationId: command.request.applicationId,
|
||||
profile: terminal.intent.profile,
|
||||
instanceId: terminal.intent.instanceId,
|
||||
cutoverId: terminal.intent.cutoverId,
|
||||
generation: terminal.intent.generation,
|
||||
activationDigest: terminal.intent.activationDigest,
|
||||
applicationPlanDigest: terminal.plan.applicationPlanDigest,
|
||||
sourceHeadDigest: head.headDigest,
|
||||
domains,
|
||||
adapterCount,
|
||||
completedAtMs: command.request.completedAtMs,
|
||||
});
|
||||
const serialized = localReconciliationCompletionReceiptContents(receipt);
|
||||
preflightPublishedFile(
|
||||
selected.receipt,
|
||||
serialized,
|
||||
0o600,
|
||||
uid,
|
||||
'reconciliation completion receipt',
|
||||
);
|
||||
publishExactFile(
|
||||
selected.receipt,
|
||||
serialized,
|
||||
0o600,
|
||||
uid,
|
||||
'reconciliation completion receipt',
|
||||
);
|
||||
dependencies.afterReceiptPublished?.();
|
||||
}
|
||||
sealCompletion(selected, uid);
|
||||
dependencies.afterTerminalSealed?.();
|
||||
head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
terminal.intent.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (head.state !== 'reconciliation_completed') {
|
||||
assertSourceHead(head, receipt.sourceHeadDigest, automation);
|
||||
if (automation !== null) {
|
||||
validateLocalReconciliationAutomationAppliedStorage(
|
||||
automation.paths,
|
||||
automation.intent,
|
||||
uid,
|
||||
);
|
||||
}
|
||||
head = advanceCompletedHead(terminal, receipt, uid);
|
||||
} else if (head.sourceRecordDigest !== receipt.completionDigest) {
|
||||
fail('completed head is detached from receipt');
|
||||
}
|
||||
dependencies.afterHeadAdvanced?.();
|
||||
if (automation !== null) {
|
||||
collectLocalReconciliationAutomationCompletedStorage(
|
||||
automation.paths,
|
||||
automation.intent,
|
||||
uid,
|
||||
);
|
||||
dependencies.afterBackupCollected?.();
|
||||
}
|
||||
return result(command.operation, status, receipt, head);
|
||||
}
|
||||
|
||||
export async function verifyLocalReconciliationCompletion(
|
||||
value: unknown,
|
||||
dependencies: LocalReconciliationCompletionDependencies = {},
|
||||
): Promise<Readonly<LocalReconciliationCompletionResult>> {
|
||||
const command = normalizeLocalReconciliationCompletionVerifyCommand(value);
|
||||
const uid = currentIdentity().uid;
|
||||
for (const [directory, label] of [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[command.options.applicationRoot, 'applicationRoot'],
|
||||
[command.options.completionRoot, 'completionRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
}
|
||||
const selected = completionPaths(
|
||||
command.options.completionRoot,
|
||||
command.request.completionId,
|
||||
);
|
||||
validateDirectory(selected.root, uid, [0o500], 'receipt directory');
|
||||
validateCatalog(selected, true);
|
||||
const receipt = stableReceipt(selected, uid, [0o400]);
|
||||
if (
|
||||
receipt.applicationId !== command.request.applicationId ||
|
||||
receipt.completionDigest !== command.request.expectedCompletionDigest
|
||||
) {
|
||||
fail('verify command is detached from receipt');
|
||||
}
|
||||
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||
command.options.applicationRoot,
|
||||
command.request.applicationId,
|
||||
uid,
|
||||
);
|
||||
validateApplication(
|
||||
terminal,
|
||||
command.request.applicationId,
|
||||
receipt.applicationPlanDigest,
|
||||
command.options.deploymentRoot,
|
||||
command.options.applicationRoot,
|
||||
);
|
||||
const syntheticCompleteCommand = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: 'local.deployment.reconciliation.complete' as const,
|
||||
options: command.options,
|
||||
request: Object.freeze({
|
||||
completionId: receipt.completionId,
|
||||
applicationId: receipt.applicationId,
|
||||
expectedApplicationPlanDigest: receipt.applicationPlanDigest,
|
||||
expectedHeadDigest: receipt.sourceHeadDigest,
|
||||
automation: command.request.automation,
|
||||
completedAtMs: receipt.completedAtMs,
|
||||
}),
|
||||
});
|
||||
const automation = await automationProof(
|
||||
syntheticCompleteCommand,
|
||||
terminal,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation);
|
||||
validateReceiptBinding(
|
||||
receipt,
|
||||
terminal,
|
||||
domains,
|
||||
command.request.completionId,
|
||||
command.request.applicationId,
|
||||
);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
terminal.intent.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
head.state !== 'reconciliation_completed' ||
|
||||
head.sourceRecordDigest !== receipt.completionDigest
|
||||
) {
|
||||
fail('completion receipt is detached from instance head');
|
||||
}
|
||||
if (automation !== null) {
|
||||
validateLocalReconciliationAutomationCompletedStorage(
|
||||
automation.paths,
|
||||
uid,
|
||||
);
|
||||
}
|
||||
return result(command.operation, 'verified', receipt, head);
|
||||
}
|
||||
|
||||
export function completeLocalReconciliationCommandFile(
|
||||
filePath: string,
|
||||
dependencies: LocalReconciliationCompletionDependencies = {},
|
||||
) {
|
||||
return completeLocalReconciliation(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyLocalReconciliationCompletionCommandFile(
|
||||
filePath: string,
|
||||
dependencies: LocalReconciliationCompletionDependencies = {},
|
||||
) {
|
||||
return verifyLocalReconciliationCompletion(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
export type {
|
||||
LocalReconciliationCompletionOptions,
|
||||
LocalReconciliationCompletionVerifyCommand,
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||
type LocalReconciliationPlanDomain,
|
||||
} from '../planning/contract';
|
||||
|
||||
const RECEIPT_SCHEMA = 'qinglong3-local-reconciliation-completion-receipt';
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface LocalReconciliationCompletionDomainEvidence {
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly action: 'no_effect' | 'adapter_required';
|
||||
readonly evidenceKind: 'application_summary' | 'automation_apply';
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_completed';
|
||||
readonly completionId: string;
|
||||
readonly applicationId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly instanceId: string;
|
||||
readonly cutoverId: string;
|
||||
readonly generation: number;
|
||||
readonly activationDigest: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly sourceHeadDigest: string;
|
||||
readonly domains: readonly Readonly<LocalReconciliationCompletionDomainEvidence>[];
|
||||
readonly adapterCount: 0 | 1;
|
||||
readonly completedAtMs: number;
|
||||
readonly completionDigest: string;
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation completion evidence ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
fail(`${label} must be an object`);
|
||||
}
|
||||
const selected = value as Record<string, unknown>;
|
||||
const actual = Object.keys(selected).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
fail(`${label} shape is invalid`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function domainEvidence(
|
||||
value: unknown,
|
||||
expectedDomain: LocalReconciliationPlanDomain,
|
||||
): Readonly<LocalReconciliationCompletionDomainEvidence> {
|
||||
const selected = exact(
|
||||
value,
|
||||
['action', 'domain', 'evidenceDigest', 'evidenceKind'],
|
||||
'domain evidence',
|
||||
);
|
||||
const noEffect =
|
||||
selected.action === 'no_effect' &&
|
||||
selected.evidenceKind === 'application_summary';
|
||||
const automation =
|
||||
expectedDomain === 'automation' &&
|
||||
selected.action === 'adapter_required' &&
|
||||
selected.evidenceKind === 'automation_apply';
|
||||
if (
|
||||
selected.domain !== expectedDomain ||
|
||||
(!noEffect && !automation) ||
|
||||
typeof selected.evidenceDigest !== 'string' ||
|
||||
!DIGEST.test(selected.evidenceDigest)
|
||||
) {
|
||||
fail('domain evidence is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
domain: expectedDomain,
|
||||
action: selected.action,
|
||||
evidenceKind: selected.evidenceKind,
|
||||
evidenceDigest: selected.evidenceDigest,
|
||||
}) as Readonly<LocalReconciliationCompletionDomainEvidence>;
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationCompletionReceipt(
|
||||
input: Omit<
|
||||
LocalReconciliationCompletionReceipt,
|
||||
'schema' | 'schemaVersion' | 'state' | 'completionDigest'
|
||||
>,
|
||||
): Readonly<LocalReconciliationCompletionReceipt> {
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_completed' as const,
|
||||
...input,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
completionDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationCompletionReceipt(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionReceipt> {
|
||||
const selected = exact(
|
||||
value,
|
||||
[
|
||||
'activationDigest',
|
||||
'adapterCount',
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'completedAtMs',
|
||||
'completionDigest',
|
||||
'completionId',
|
||||
'cutoverId',
|
||||
'domains',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'profile',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'sourceHeadDigest',
|
||||
'state',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
if (!Array.isArray(selected.domains) || selected.domains.length !== 8) {
|
||||
fail('receipt domain catalog is invalid');
|
||||
}
|
||||
const rawDomains = selected.domains as unknown[];
|
||||
const domains = Object.freeze(
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
|
||||
domainEvidence(rawDomains[index], domain),
|
||||
),
|
||||
);
|
||||
const adapterCount = domains.filter(
|
||||
(domain) => domain.action === 'adapter_required',
|
||||
).length;
|
||||
const { completionDigest, ...raw } = selected;
|
||||
const normalized = Object.freeze({ ...raw, domains });
|
||||
if (
|
||||
selected.schema !== RECEIPT_SCHEMA ||
|
||||
selected.schemaVersion !== 1 ||
|
||||
selected.state !== 'reconciliation_completed' ||
|
||||
typeof selected.completionId !== 'string' ||
|
||||
!UUID_V4.test(selected.completionId) ||
|
||||
typeof selected.applicationId !== 'string' ||
|
||||
!UUID_V4.test(selected.applicationId) ||
|
||||
(selected.profile !== 'edge' && selected.profile !== 'standalone') ||
|
||||
typeof selected.instanceId !== 'string' ||
|
||||
selected.instanceId.length < 1 ||
|
||||
typeof selected.cutoverId !== 'string' ||
|
||||
selected.cutoverId.length < 1 ||
|
||||
!Number.isSafeInteger(selected.generation) ||
|
||||
(selected.generation as number) < 1 ||
|
||||
![
|
||||
selected.activationDigest,
|
||||
selected.applicationPlanDigest,
|
||||
selected.sourceHeadDigest,
|
||||
completionDigest,
|
||||
].every(
|
||||
(candidate) => typeof candidate === 'string' && DIGEST.test(candidate),
|
||||
) ||
|
||||
(selected.adapterCount !== 0 && selected.adapterCount !== 1) ||
|
||||
selected.adapterCount !== adapterCount ||
|
||||
!Number.isSafeInteger(selected.completedAtMs) ||
|
||||
(selected.completedAtMs as number) < 0 ||
|
||||
cutoverDigest(normalized) !== completionDigest
|
||||
) {
|
||||
fail('receipt binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
completionDigest,
|
||||
}) as unknown as Readonly<LocalReconciliationCompletionReceipt>;
|
||||
}
|
||||
|
||||
export function localReconciliationCompletionReceiptContents(
|
||||
receipt: Readonly<LocalReconciliationCompletionReceipt>,
|
||||
): string {
|
||||
return `${JSON.stringify(receipt, null, 2)}\n`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
commitLocalReconciliationCapture,
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationAutomationDecision,
|
||||
completeLocalReconciliation,
|
||||
applyLocalReconciliationAutomation,
|
||||
commitLocalReconciliationPlan,
|
||||
commitLocalReconciliationReview,
|
||||
@@ -27,6 +28,7 @@ const {
|
||||
verifyLocalReconciliationAutomationDecision,
|
||||
verifyLocalReconciliationAutomationApply,
|
||||
verifyLocalReconciliationAutomationPlan,
|
||||
verifyLocalReconciliationCompletion,
|
||||
verifyLocalReconciliationPlan,
|
||||
verifyLocalReconciliationReview,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
@@ -42,6 +44,7 @@ const {
|
||||
} = require('@qinglong/local-sqlite/data-directory-application-commit');
|
||||
const {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
assertLocalCutoverTargetHead,
|
||||
claimLocalCutoverInstance,
|
||||
readLocalCutoverInstanceHead,
|
||||
} = require('../dist/deployment/cutover/instanceLineage.js');
|
||||
@@ -1356,6 +1359,108 @@ function automationDecisionCommitFixture(
|
||||
};
|
||||
}
|
||||
|
||||
async function appliedAutomationFixture(t, options = {}) {
|
||||
const state = await plannedAutomationFixture(t, {
|
||||
suffix: options.suffix ?? 'completion-applied',
|
||||
planId: options.planId ?? '00000000-0000-4000-8000-000000000481',
|
||||
reviewId: options.reviewId ?? '00000000-0000-4000-8000-000000000482',
|
||||
applicationId:
|
||||
options.applicationId ?? '00000000-0000-4000-8000-000000000483',
|
||||
automationId:
|
||||
options.automationId ?? '00000000-0000-4000-8000-000000000484',
|
||||
readyTarget: true,
|
||||
});
|
||||
assert.equal(state.application.outcome, 'adapter_and_manual_required');
|
||||
const decisionId =
|
||||
options.decisionId ?? '019b0000-0000-7000-8000-000000000481';
|
||||
const review = automationDecisionReviewFile(
|
||||
state,
|
||||
decisionId,
|
||||
'adopt',
|
||||
'reviewed_lossless',
|
||||
options.suffix ?? 'completion-applied',
|
||||
);
|
||||
const prepareCommand = automationDecisionPrepareCommand(state, decisionId);
|
||||
const prepared = await prepareLocalReconciliationAutomationDecision(
|
||||
prepareCommand,
|
||||
);
|
||||
const commit = automationDecisionCommitFixture(
|
||||
state,
|
||||
{ result: prepared, commandOptions: prepareCommand.options },
|
||||
review.filePath,
|
||||
);
|
||||
const decision = await commitLocalReconciliationAutomationDecision(
|
||||
commit.command,
|
||||
commit.dependencies,
|
||||
);
|
||||
const automationApplyRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
`automation-apply-${options.suffix ?? 'completion-applied'}`,
|
||||
);
|
||||
fs.mkdirSync(automationApplyRoot, { mode: 0o700 });
|
||||
const applyOptions = {
|
||||
...prepareCommand.options,
|
||||
automationApplyRoot,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
ownerPepperKeyringDirectory:
|
||||
state.command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: state.command.options.credentialFilePath,
|
||||
};
|
||||
const appliedAtMs = commit.command.request.committedAtMs + 1;
|
||||
const applyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.automation.apply',
|
||||
options: applyOptions,
|
||||
request: {
|
||||
decisionId,
|
||||
automationId: state.automationCommand.request.automationId,
|
||||
expectedDecisionDigest: decision.decisionDigest,
|
||||
expectedHeadDigest: decision.instanceHeadDigest,
|
||||
mutationId: options.mutationId ?? '00000000-0000-4000-8000-000000000485',
|
||||
requestId: `automation-apply-${options.suffix ?? 'completion-applied'}`,
|
||||
appliedAtMs,
|
||||
},
|
||||
};
|
||||
const applyDependencies = {
|
||||
async openAuthenticationDatabase() {
|
||||
return { async close() {} };
|
||||
},
|
||||
async authenticate(_database, authenticateOptions) {
|
||||
const authenticatedAtMs = authenticateOptions.now();
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'review-owner' },
|
||||
authenticationId: 'local_reconciliation_automation_apply:test',
|
||||
authenticatedAtMs,
|
||||
expiresAtMs: authenticatedAtMs + 60 * 60 * 1_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
databaseFence: {
|
||||
credentialId: 'review-owner',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'review-owner-v1',
|
||||
pepperVersion: 1,
|
||||
},
|
||||
async confirm() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const applied = await applyLocalReconciliationAutomation(
|
||||
applyCommand,
|
||||
applyDependencies,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
decisionId,
|
||||
decision,
|
||||
automationApplyRoot,
|
||||
applyOptions,
|
||||
applyCommand,
|
||||
applyDependencies,
|
||||
applied,
|
||||
};
|
||||
}
|
||||
|
||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||
const source =
|
||||
mode === 'main_only_immutable'
|
||||
@@ -2908,6 +3013,237 @@ test('application coordinator plans eight content-free domains and verifies with
|
||||
assert.equal(cli.stdout.includes('Crontabs'), false);
|
||||
});
|
||||
|
||||
test('completion fence seals all eight no-effect domains before target restart', async (t) => {
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000421',
|
||||
reviewId: '00000000-0000-4000-8000-000000000422',
|
||||
applicationId: '00000000-0000-4000-8000-000000000423',
|
||||
reviewSuffix: 'completion-no-effect',
|
||||
createDefaultSidecars: false,
|
||||
initializeDatabases: automationDatabaseInitializer(),
|
||||
mutateTarget(paths) {
|
||||
return mutateAutomationTarget(paths);
|
||||
},
|
||||
});
|
||||
const prepared = await prepareLocalReconciliationApplication(
|
||||
state.prepareApplicationCommand,
|
||||
);
|
||||
const application = await commitLocalReconciliationApplication(
|
||||
applicationCommitCommand(state, prepared),
|
||||
);
|
||||
assert.equal(application.outcome, 'no_effect_ready');
|
||||
const completionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'completion-no-effect',
|
||||
);
|
||||
fs.mkdirSync(completionRoot, { mode: 0o700 });
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
completionRoot,
|
||||
automation: null,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
completionId: '00000000-0000-4000-8000-000000000424',
|
||||
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||
expectedApplicationPlanDigest: application.applicationPlanDigest,
|
||||
expectedHeadDigest: application.instanceHeadDigest,
|
||||
automation: null,
|
||||
completedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 4,
|
||||
},
|
||||
};
|
||||
for (const boundary of [
|
||||
'afterReceiptPublished',
|
||||
'afterTerminalSealed',
|
||||
'afterHeadAdvanced',
|
||||
]) {
|
||||
await assert.rejects(
|
||||
completeLocalReconciliation(command, {
|
||||
[boundary]() {
|
||||
throw new Error(`completion ${boundary} response loss`);
|
||||
},
|
||||
}),
|
||||
new RegExp(`completion ${boundary} response loss`),
|
||||
);
|
||||
}
|
||||
const completed = await completeLocalReconciliation(command);
|
||||
assert.equal(completed.status, 'existing');
|
||||
assert.equal(completed.state, 'reconciliation_completed');
|
||||
assert.equal(completed.domainCount, 8);
|
||||
assert.equal(completed.adapterCount, 0);
|
||||
const completionDirectory = path.join(
|
||||
completionRoot,
|
||||
command.request.completionId,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(completionDirectory), ['receipt.json']);
|
||||
assert.equal(fs.statSync(completionDirectory).mode & 0o777, 0o500);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(completionDirectory, 'receipt.json')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
const receipt = JSON.parse(
|
||||
fs.readFileSync(path.join(completionDirectory, 'receipt.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(receipt.domains.length, 8);
|
||||
assert.equal(
|
||||
receipt.domains.every(
|
||||
(domain) =>
|
||||
domain.action === 'no_effect' &&
|
||||
domain.evidenceKind === 'application_summary',
|
||||
),
|
||||
true,
|
||||
);
|
||||
const verifyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.complete.verify',
|
||||
options: command.options,
|
||||
request: {
|
||||
completionId: command.request.completionId,
|
||||
applicationId: command.request.applicationId,
|
||||
expectedCompletionDigest: completed.completionDigest,
|
||||
automation: null,
|
||||
},
|
||||
};
|
||||
const verified = await verifyLocalReconciliationCompletion(verifyCommand);
|
||||
assert.equal(verified.status, 'verified');
|
||||
const commandPath = path.join(state.deploymentRoot, 'completion-verify.json');
|
||||
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||
'reconciliation-complete-verify',
|
||||
'--command-file',
|
||||
commandPath,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(cli.status, 0, cli.stderr);
|
||||
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||
assert.equal(cli.stdout.includes(completionRoot), false);
|
||||
const identity = {
|
||||
options: { deploymentRoot: state.deploymentRoot },
|
||||
request: {
|
||||
cutoverId: state.captureCommand.request.cutoverId,
|
||||
profile: state.captureCommand.request.profile,
|
||||
instanceId: state.captureCommand.request.instanceId,
|
||||
expectedActivationDigest:
|
||||
state.captureCommand.request.expectedActivationDigest,
|
||||
requestedAtMs: command.request.completedAtMs + 1,
|
||||
},
|
||||
};
|
||||
const restartHead = assertLocalCutoverTargetHead(identity, state.uid);
|
||||
assert.equal(restartHead.state, 'reconciliation_completed');
|
||||
const activeHead = advanceLocalCutoverInstanceHead(
|
||||
identity,
|
||||
state.uid,
|
||||
'target_active',
|
||||
2,
|
||||
'e'.repeat(64),
|
||||
);
|
||||
assert.equal(activeHead.state, 'target_active');
|
||||
assert.equal(activeHead.generation, 2);
|
||||
});
|
||||
|
||||
test('completion fence retains automation rollback backup while other domains remain manual', async (t) => {
|
||||
const state = await appliedAutomationFixture(t, {
|
||||
suffix: 'completion-fence',
|
||||
planId: '00000000-0000-4000-8000-000000000491',
|
||||
reviewId: '00000000-0000-4000-8000-000000000492',
|
||||
applicationId: '00000000-0000-4000-8000-000000000493',
|
||||
automationId: '00000000-0000-4000-8000-000000000494',
|
||||
decisionId: '019b0000-0000-7000-8000-000000000491',
|
||||
mutationId: '00000000-0000-4000-8000-000000000495',
|
||||
});
|
||||
const completionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'completion-automation-applied',
|
||||
);
|
||||
fs.mkdirSync(completionRoot, { mode: 0o700 });
|
||||
const automation = {
|
||||
automationId: state.automationCommand.request.automationId,
|
||||
decisionId: state.decisionId,
|
||||
expectedApplyDigest: state.applied.applyDigest,
|
||||
};
|
||||
const completionOptions = {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
completionRoot,
|
||||
automation: {
|
||||
automationRoot: state.automationRoot,
|
||||
automationDecisionRoot: state.automationDecisionRoot,
|
||||
automationApplyRoot: state.automationApplyRoot,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
},
|
||||
allowRootService: rootAcknowledgement(),
|
||||
};
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: completionOptions,
|
||||
request: {
|
||||
completionId: '00000000-0000-4000-8000-000000000496',
|
||||
applicationId: state.application.applicationId,
|
||||
expectedApplicationPlanDigest: state.application.applicationPlanDigest,
|
||||
expectedHeadDigest: state.applied.instanceHeadDigest,
|
||||
automation,
|
||||
completedAtMs: state.applyCommand.request.appliedAtMs + 1,
|
||||
},
|
||||
};
|
||||
const applyRoot = path.join(
|
||||
state.automationApplyRoot,
|
||||
state.automationCommand.request.automationId,
|
||||
);
|
||||
const backupRoot = path.join(applyRoot, 'backup');
|
||||
const backupPath = path.join(backupRoot, 'before.sqlite');
|
||||
const rollbackRoot = path.join(applyRoot, 'rollback-work');
|
||||
assert.equal(fs.existsSync(backupPath), true);
|
||||
await assert.rejects(
|
||||
completeLocalReconciliation(command),
|
||||
/secret_and_config is not terminally reconciled/,
|
||||
);
|
||||
assert.equal(fs.existsSync(backupPath), true);
|
||||
assert.deepEqual(fs.readdirSync(backupRoot), ['before.sqlite']);
|
||||
assert.deepEqual(fs.readdirSync(rollbackRoot), []);
|
||||
assert.equal(fs.statSync(backupRoot).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(rollbackRoot).mode & 0o777, 0o700);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(completionRoot, command.request.completionId)),
|
||||
false,
|
||||
);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.captureCommand.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(head.state, 'reconciliation_automation_applied');
|
||||
assert.equal(head.sourceRecordDigest, state.applied.applyDigest);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalCutoverTargetHead(
|
||||
{
|
||||
options: { deploymentRoot: state.deploymentRoot },
|
||||
request: {
|
||||
cutoverId: state.captureCommand.request.cutoverId,
|
||||
profile: state.captureCommand.request.profile,
|
||||
instanceId: state.captureCommand.request.instanceId,
|
||||
expectedActivationDigest:
|
||||
state.captureCommand.request.expectedActivationDigest,
|
||||
requestedAtMs: command.request.completedAtMs + 1,
|
||||
},
|
||||
},
|
||||
state.uid,
|
||||
),
|
||||
/not bound to the instance lineage head/,
|
||||
);
|
||||
});
|
||||
|
||||
test('automation adapter builds a sealed row plan with bounded conflict evidence', async (t) => {
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000431',
|
||||
|
||||
@@ -2042,6 +2042,11 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
'src/deployment/reconciliation/application/automation/applyEvidence.ts' &&
|
||||
specifier === '@qinglong/local-sqlite/rollout-safety'
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/deployment/reconciliation/completion/coordinator.ts' &&
|
||||
specifier === '@qinglong/local-sqlite/rollout-safety'
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/deployment/reconciliation/application/automation/planReader.ts' &&
|
||||
|
||||
@@ -2158,7 +2158,12 @@ test('confines reconciliation automation apply authority to exact coordinators',
|
||||
root,
|
||||
'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation',
|
||||
);
|
||||
const completionDirectory = path.join(
|
||||
root,
|
||||
'packages/ql3-local-owner-cli/src/deployment/reconciliation/completion',
|
||||
);
|
||||
fs.mkdirSync(automationDirectory, { recursive: true });
|
||||
fs.mkdirSync(completionDirectory, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(automationDirectory, 'decisionCoordinator.ts'),
|
||||
[
|
||||
@@ -2182,6 +2187,14 @@ test('confines reconciliation automation apply authority to exact coordinators',
|
||||
path.join(automationDirectory, 'applyEvidence.ts'),
|
||||
"import type { Evidence } from '@qinglong/local-sqlite/rollout-safety';",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(completionDirectory, 'coordinator.ts'),
|
||||
"import { inspect } from '@qinglong/local-sqlite/rollout-safety';",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(completionDirectory, 'neighbor.ts'),
|
||||
"import { backup } from '@qinglong/local-sqlite/rollout-safety';",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(automationDirectory, 'neighbor.ts'),
|
||||
[
|
||||
@@ -2224,6 +2237,11 @@ test('confines reconciliation automation apply authority to exact coordinators',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/neighbor.ts',
|
||||
specifier: '@qinglong/runtime-core/security',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/neighbor.ts',
|
||||
specifier: '@qinglong/local-sqlite/rollout-safety',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 169,
|
||||
sourceFiles: 172,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 50,
|
||||
nestedSourceFiles: 168,
|
||||
nestedSourceFiles: 171,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user