mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(local): recover reconciliation automation apply
This commit is contained in:
@@ -55,6 +55,30 @@
|
|||||||
signed decision,完成 target 写前可恢复 backup 证明、Project Policy/credential 复验、幂等原子 transaction/replay、post-apply evidence 与显式
|
signed decision,完成 target 写前可恢复 backup 证明、Project Policy/credential 复验、幂等原子 transaction/replay、post-apply evidence 与显式
|
||||||
rollback。当前状态仍不代表 applied、restart-ready 或 reconciliation-complete。进入 apply 写语义时必须重新选择 SQLite/Docker 数据门禁,不能
|
rollback。当前状态仍不代表 applied、restart-ready 或 reconciliation-complete。进入 apply 写语义时必须重新选择 SQLite/Docker 数据门禁,不能
|
||||||
复用本切片只读证明。
|
复用本切片只读证明。
|
||||||
|
|
||||||
|
第三切片已消费上述 signed decision 并建立首个真实写入闭环,而没有新增第二套 Task/Trigger DML。Local Admin 的 plan-bound verifier 现在只把
|
||||||
|
已验证的 decision scope 交给既有 `LocalSqliteLegacyAdoptionPublisher`;后者继续以 `BEGIN IMMEDIATE`、Project/RoleBinding version fence、
|
||||||
|
mutation/decision exact replay、Task/Trigger collision 检查、security audit 与 adoption ledger 作为唯一写入 authority。Local Owner 新增
|
||||||
|
`reconciliation-automation-apply|apply-verify|apply-rollback`,实例 lineage 严格前进为
|
||||||
|
`reconciliation_automation_reviewed → reconciliation_automation_apply_prepared → reconciliation_automation_applied →
|
||||||
|
reconciliation_automation_rolled_back`。apply 在 DML 前重新证明原 target 仍处于同一 stopped authority,直接复用 `local-sqlite/rollout-safety`
|
||||||
|
的 online SQLite backup、readiness/snapshot audit 和 no-replace stage/link publication;backup、intent 与 prepared head 完成后,再重新打开 sealed
|
||||||
|
Legacy bundle、逐行 plan 和 `0400/0500` signed authorization,并把最多 5 分钟的当前强认证 User 与原 reviewer 精确匹配。事务提交前还会复验
|
||||||
|
prepared head、sealed bundle、authorization inode、当前 credential 与 Project Policy fence。database commit、receipt、head 任一 response-loss
|
||||||
|
窗口通过相同 mutation/decision ledger 收敛,不能重复插入 Task/Trigger。
|
||||||
|
|
||||||
|
apply receipt 绑定 publication digest、adopted Task/Trigger/skipped counters、写后完整 SQLite snapshot 和写前 backup;verify 同时检查 signed
|
||||||
|
decision、instance head 与当前 target snapshot,stdout 不返回 reviewer、路径、row digest 或 command。rollback 不是裸文件覆盖:它先以当前 reviewer
|
||||||
|
强认证和当前 Project Policy 重放已存在 publication,再要求 target 精确等于 apply snapshot,最后调用 `preserveDatabaseIdentity=true` 的 restore,
|
||||||
|
保持 activation 所绑定的 SQLite inode。restore、rollback receipt 和 rollback head 的 response-loss 同样可恢复;可恢复 backup authority 因仍需支持
|
||||||
|
apply 后显式 rollback,保持 current-UID `0700/0600`,不伪装成已经归档的 `0500/0400` terminal。聚焦 reconciliation 套件仍为
|
||||||
|
`44 total / 42 pass / 2 conditional Docker skip / 0 fail`,但成功场景现在额外覆盖首次 apply、mutation replay、不同 reviewer 拒绝、六个
|
||||||
|
backup/apply/rollback response-loss 窗口、content-free CLI verify、identity-preserving rollback 与 rollback replay。完整 Local Owner 在受限沙箱为
|
||||||
|
`266 total / 256 pass / 7 conditional skip / 3 loopback-listen EPERM`,两个对应文件在沙箱外 `15/15` 通过;package/dependency boundary 为
|
||||||
|
`69/69`。workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Local Owner 为
|
||||||
|
`168 source / 167 nested / 1 root binary entry`,没有新增 package、production dependency、daemon、listener、timer 或 `src/` 根平铺。
|
||||||
|
D-393 仍未代表完整 reconciliation:Automation 之外的 Secret、Plugin、Identity、history 等领域 adapter 尚未 apply,target service 也未获得 restart
|
||||||
|
authority;后续必须先完成 apply-root 的 retention/seal 策略和跨领域 completion fence,再进入 target restart/readiness。
|
||||||
- D-392/ADR-0485(已接受):D-391 的 signed review 不能直接获得通用 DML authority;表级 `adopt_legacy/retain_both` 也不能证明
|
- 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 语义。
|
Automation 行级 command/trigger 兼容,更不能覆盖 Secret custody、append-only history、Plugin/AI 外部资产与 Identity/Policy 语义。
|
||||||
因此既有 Local Owner 新增 `reconciliation.application.prepare|commit|verify`,以
|
因此既有 Local Owner 新增 `reconciliation.application.prepare|commit|verify`,以
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export type LocalCutoverInstanceHeadState =
|
|||||||
| 'reconciliation_automation_planned'
|
| 'reconciliation_automation_planned'
|
||||||
| 'reconciliation_automation_decision_prepared'
|
| 'reconciliation_automation_decision_prepared'
|
||||||
| 'reconciliation_automation_reviewed'
|
| 'reconciliation_automation_reviewed'
|
||||||
|
| 'reconciliation_automation_apply_prepared'
|
||||||
|
| 'reconciliation_automation_applied'
|
||||||
|
| 'reconciliation_automation_rolled_back'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -176,6 +179,9 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
|||||||
head.state !== 'reconciliation_automation_planned' &&
|
head.state !== 'reconciliation_automation_planned' &&
|
||||||
head.state !== 'reconciliation_automation_decision_prepared' &&
|
head.state !== 'reconciliation_automation_decision_prepared' &&
|
||||||
head.state !== 'reconciliation_automation_reviewed' &&
|
head.state !== 'reconciliation_automation_reviewed' &&
|
||||||
|
head.state !== 'reconciliation_automation_apply_prepared' &&
|
||||||
|
head.state !== 'reconciliation_automation_applied' &&
|
||||||
|
head.state !== 'reconciliation_automation_rolled_back' &&
|
||||||
head.state !== 'rollback_prepared' &&
|
head.state !== 'rollback_prepared' &&
|
||||||
head.state !== 'legacy_restart_requested' &&
|
head.state !== 'legacy_restart_requested' &&
|
||||||
head.state !== 'legacy_running' &&
|
head.state !== 'legacy_running' &&
|
||||||
@@ -353,6 +359,9 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
| 'reconciliation_automation_planned'
|
| 'reconciliation_automation_planned'
|
||||||
| 'reconciliation_automation_decision_prepared'
|
| 'reconciliation_automation_decision_prepared'
|
||||||
| 'reconciliation_automation_reviewed'
|
| 'reconciliation_automation_reviewed'
|
||||||
|
| 'reconciliation_automation_apply_prepared'
|
||||||
|
| 'reconciliation_automation_applied'
|
||||||
|
| 'reconciliation_automation_rolled_back'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -402,6 +411,9 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_automation_planned' ||
|
current.state === 'reconciliation_automation_planned' ||
|
||||||
current.state === 'reconciliation_automation_decision_prepared' ||
|
current.state === 'reconciliation_automation_decision_prepared' ||
|
||||||
current.state === 'reconciliation_automation_reviewed' ||
|
current.state === 'reconciliation_automation_reviewed' ||
|
||||||
|
current.state === 'reconciliation_automation_apply_prepared' ||
|
||||||
|
current.state === 'reconciliation_automation_applied' ||
|
||||||
|
current.state === 'reconciliation_automation_rolled_back' ||
|
||||||
current.state === 'legacy_restart_requested' ||
|
current.state === 'legacy_restart_requested' ||
|
||||||
current.state === 'legacy_running' ||
|
current.state === 'legacy_running' ||
|
||||||
current.state === 'legacy_ready')
|
current.state === 'legacy_ready')
|
||||||
@@ -439,6 +451,12 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_automation_planned') ||
|
current.state === 'reconciliation_automation_planned') ||
|
||||||
(state === 'reconciliation_automation_reviewed' &&
|
(state === 'reconciliation_automation_reviewed' &&
|
||||||
current.state === 'reconciliation_automation_decision_prepared') ||
|
current.state === 'reconciliation_automation_decision_prepared') ||
|
||||||
|
(state === 'reconciliation_automation_apply_prepared' &&
|
||||||
|
current.state === 'reconciliation_automation_reviewed') ||
|
||||||
|
(state === 'reconciliation_automation_applied' &&
|
||||||
|
current.state === 'reconciliation_automation_apply_prepared') ||
|
||||||
|
(state === 'reconciliation_automation_rolled_back' &&
|
||||||
|
current.state === 'reconciliation_automation_applied') ||
|
||||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||||
(state === 'legacy_restart_requested' &&
|
(state === 'legacy_restart_requested' &&
|
||||||
current.state === 'rollback_prepared') ||
|
current.state === 'rollback_prepared') ||
|
||||||
|
|||||||
@@ -142,9 +142,18 @@ import {
|
|||||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||||
prepareLocalReconciliationAutomationDecision,
|
prepareLocalReconciliationAutomationDecision,
|
||||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
readLocalReconciliationAutomationDecisionTerminal,
|
||||||
verifyLocalReconciliationAutomationDecision,
|
verifyLocalReconciliationAutomationDecision,
|
||||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||||
} from './reconciliation/application/automation/decisionCoordinator';
|
} from './reconciliation/application/automation/decisionCoordinator';
|
||||||
|
import {
|
||||||
|
applyLocalReconciliationAutomation,
|
||||||
|
applyLocalReconciliationAutomationCommandFile,
|
||||||
|
rollbackLocalReconciliationAutomationApply,
|
||||||
|
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
verifyLocalReconciliationAutomationApply,
|
||||||
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
} from './reconciliation/application/automation/applyCoordinator';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
@@ -173,12 +182,31 @@ export {
|
|||||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||||
prepareLocalReconciliationAutomationDecision,
|
prepareLocalReconciliationAutomationDecision,
|
||||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
readLocalReconciliationAutomationDecisionTerminal,
|
||||||
commitLocalReconciliationAutomationDecision,
|
commitLocalReconciliationAutomationDecision,
|
||||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||||
verifyLocalReconciliationAutomationDecision,
|
verifyLocalReconciliationAutomationDecision,
|
||||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
applyLocalReconciliationAutomation,
|
||||||
|
applyLocalReconciliationAutomationCommandFile,
|
||||||
|
verifyLocalReconciliationAutomationApply,
|
||||||
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
rollbackLocalReconciliationAutomationApply,
|
||||||
|
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationAutomationApplyCommand,
|
||||||
|
normalizeLocalReconciliationAutomationApplyRollbackCommand,
|
||||||
|
normalizeLocalReconciliationAutomationApplyVerifyCommand,
|
||||||
|
type LocalReconciliationAutomationApplyCommand,
|
||||||
|
type LocalReconciliationAutomationApplyOptions,
|
||||||
|
type LocalReconciliationAutomationApplyResult,
|
||||||
|
type LocalReconciliationAutomationApplyRollbackCommand,
|
||||||
|
type LocalReconciliationAutomationApplyVerifyCommand,
|
||||||
|
} from './reconciliation/application/automation/applyContract';
|
||||||
|
export { type LocalReconciliationAutomationApplyDependencies } from './reconciliation/application/automation/applyCoordinator';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
normalizeLocalReconciliationApplicationCommitCommand,
|
normalizeLocalReconciliationApplicationCommitCommand,
|
||||||
normalizeLocalReconciliationApplicationPrepareCommand,
|
normalizeLocalReconciliationApplicationPrepareCommand,
|
||||||
@@ -214,9 +242,7 @@ export {
|
|||||||
type LocalReconciliationAutomationPlanResult,
|
type LocalReconciliationAutomationPlanResult,
|
||||||
type LocalReconciliationAutomationVerifyCommand,
|
type LocalReconciliationAutomationVerifyCommand,
|
||||||
} from './reconciliation/application/automation/contract';
|
} from './reconciliation/application/automation/contract';
|
||||||
export {
|
export { type LocalReconciliationAutomationPlanDependencies } from './reconciliation/application/automation/coordinator';
|
||||||
type LocalReconciliationAutomationPlanDependencies,
|
|
||||||
} from './reconciliation/application/automation/coordinator';
|
|
||||||
export {
|
export {
|
||||||
normalizeLocalReconciliationAutomationDecisionCommitCommand,
|
normalizeLocalReconciliationAutomationDecisionCommitCommand,
|
||||||
normalizeLocalReconciliationAutomationDecisionPrepareCommand,
|
normalizeLocalReconciliationAutomationDecisionPrepareCommand,
|
||||||
@@ -229,9 +255,7 @@ export {
|
|||||||
type LocalReconciliationAutomationDecisionTerminalResult,
|
type LocalReconciliationAutomationDecisionTerminalResult,
|
||||||
type LocalReconciliationAutomationDecisionVerifyCommand,
|
type LocalReconciliationAutomationDecisionVerifyCommand,
|
||||||
} from './reconciliation/application/automation/decisionContract';
|
} from './reconciliation/application/automation/decisionContract';
|
||||||
export {
|
export { type LocalReconciliationAutomationDecisionDependencies } from './reconciliation/application/automation/decisionCoordinator';
|
||||||
type LocalReconciliationAutomationDecisionDependencies,
|
|
||||||
} from './reconciliation/application/automation/decisionCoordinator';
|
|
||||||
export {
|
export {
|
||||||
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||||
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import {
|
|||||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
applyLocalReconciliationAutomationCommandFile,
|
||||||
|
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||||
|
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
prepareLocalDeploymentCommandFile,
|
prepareLocalDeploymentCommandFile,
|
||||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||||
@@ -45,7 +48,7 @@ import {
|
|||||||
} from './localDeployment';
|
} from './localDeployment';
|
||||||
|
|
||||||
const USAGE =
|
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|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|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')) {
|
||||||
@@ -92,6 +95,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-automation-decision-prepare' &&
|
argv[0] !== 'reconciliation-automation-decision-prepare' &&
|
||||||
argv[0] !== 'reconciliation-automation-decision-commit' &&
|
argv[0] !== 'reconciliation-automation-decision-commit' &&
|
||||||
argv[0] !== 'reconciliation-automation-decision-verify' &&
|
argv[0] !== 'reconciliation-automation-decision-verify' &&
|
||||||
|
argv[0] !== 'reconciliation-automation-apply' &&
|
||||||
|
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
||||||
|
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
||||||
argv[0] !== 'compose-revision' &&
|
argv[0] !== 'compose-revision' &&
|
||||||
argv[0] !== 'compose-preflight' &&
|
argv[0] !== 'compose-preflight' &&
|
||||||
argv[0] !== 'compose-apply' &&
|
argv[0] !== 'compose-apply' &&
|
||||||
@@ -198,6 +204,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? commitLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
? commitLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-automation-decision-verify'
|
: argv[0] === 'reconciliation-automation-decision-verify'
|
||||||
? verifyLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
? verifyLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-automation-apply'
|
||||||
|
? applyLocalReconciliationAutomationCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-automation-apply-verify'
|
||||||
|
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-automation-apply-rollback'
|
||||||
|
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-revision'
|
: argv[0] === 'compose-revision'
|
||||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-preflight'
|
: argv[0] === 'compose-preflight'
|
||||||
|
|||||||
+380
@@ -0,0 +1,380 @@
|
|||||||
|
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._/@-]+$/;
|
||||||
|
const SAFE_ID = /^[A-Za-z0-9._:@-]{1,256}$/;
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyOptions {
|
||||||
|
readonly deploymentRoot: string;
|
||||||
|
readonly applicationRoot: string;
|
||||||
|
readonly automationRoot: string;
|
||||||
|
readonly automationDecisionRoot: string;
|
||||||
|
readonly automationApplyRoot: string;
|
||||||
|
readonly targetDatabasePath: string;
|
||||||
|
readonly ownerPepperKeyringDirectory: string;
|
||||||
|
readonly credentialFilePath: string;
|
||||||
|
readonly allowRootService: boolean;
|
||||||
|
readonly busyTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.automation.apply';
|
||||||
|
readonly options: Readonly<LocalReconciliationAutomationApplyOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
decisionId: string;
|
||||||
|
automationId: string;
|
||||||
|
expectedDecisionDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
mutationId: string;
|
||||||
|
requestId: string;
|
||||||
|
appliedAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyVerifyCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.automation.apply.verify';
|
||||||
|
readonly options: Readonly<LocalReconciliationAutomationApplyOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
decisionId: string;
|
||||||
|
automationId: string;
|
||||||
|
expectedApplyDigest: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyRollbackCommand {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation: 'local.deployment.reconciliation.automation.apply.rollback';
|
||||||
|
readonly options: Readonly<LocalReconciliationAutomationApplyOptions>;
|
||||||
|
readonly request: Readonly<{
|
||||||
|
decisionId: string;
|
||||||
|
automationId: string;
|
||||||
|
expectedApplyDigest: string;
|
||||||
|
expectedHeadDigest: string;
|
||||||
|
rolledBackAtMs: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyResult {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly operation:
|
||||||
|
| LocalReconciliationAutomationApplyCommand['operation']
|
||||||
|
| LocalReconciliationAutomationApplyVerifyCommand['operation']
|
||||||
|
| LocalReconciliationAutomationApplyRollbackCommand['operation'];
|
||||||
|
readonly status: 'applied' | 'existing' | 'verified' | 'rolled_back';
|
||||||
|
readonly state:
|
||||||
|
| 'reconciliation_automation_applied'
|
||||||
|
| 'reconciliation_automation_rolled_back';
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly automationId: string;
|
||||||
|
readonly applyDigest: string;
|
||||||
|
readonly publicationDigest: string;
|
||||||
|
readonly adoptedTaskCount: number;
|
||||||
|
readonly adoptedTriggerCount: number;
|
||||||
|
readonly backupSha256: string;
|
||||||
|
readonly instanceHeadDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation automation apply ${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, i) => key !== expected[i])
|
||||||
|
) {
|
||||||
|
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') > 4096
|
||||||
|
)
|
||||||
|
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 options(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyOptions> {
|
||||||
|
const selected = record(value, 'options');
|
||||||
|
const hasBusyTimeout = Object.hasOwn(selected, 'busyTimeoutMs');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
[
|
||||||
|
'allowRootService',
|
||||||
|
'applicationRoot',
|
||||||
|
'automationApplyRoot',
|
||||||
|
'automationDecisionRoot',
|
||||||
|
'automationRoot',
|
||||||
|
'credentialFilePath',
|
||||||
|
'deploymentRoot',
|
||||||
|
'ownerPepperKeyringDirectory',
|
||||||
|
'targetDatabasePath',
|
||||||
|
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||||
|
],
|
||||||
|
'options',
|
||||||
|
);
|
||||||
|
const identity = currentIdentity();
|
||||||
|
if (
|
||||||
|
typeof selected.allowRootService !== 'boolean' ||
|
||||||
|
(identity.uid === 0) !== selected.allowRootService
|
||||||
|
)
|
||||||
|
fail('command identity is invalid');
|
||||||
|
const normalized = {
|
||||||
|
deploymentRoot: safePath(selected.deploymentRoot, 'deploymentRoot'),
|
||||||
|
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
|
||||||
|
automationRoot: safePath(selected.automationRoot, 'automationRoot'),
|
||||||
|
automationDecisionRoot: safePath(
|
||||||
|
selected.automationDecisionRoot,
|
||||||
|
'automationDecisionRoot',
|
||||||
|
),
|
||||||
|
automationApplyRoot: safePath(
|
||||||
|
selected.automationApplyRoot,
|
||||||
|
'automationApplyRoot',
|
||||||
|
),
|
||||||
|
targetDatabasePath: safePath(
|
||||||
|
selected.targetDatabasePath,
|
||||||
|
'targetDatabasePath',
|
||||||
|
),
|
||||||
|
ownerPepperKeyringDirectory: safePath(
|
||||||
|
selected.ownerPepperKeyringDirectory,
|
||||||
|
'ownerPepperKeyringDirectory',
|
||||||
|
),
|
||||||
|
credentialFilePath: safePath(
|
||||||
|
selected.credentialFilePath,
|
||||||
|
'credentialFilePath',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const roots = [
|
||||||
|
normalized.deploymentRoot,
|
||||||
|
normalized.applicationRoot,
|
||||||
|
normalized.automationRoot,
|
||||||
|
normalized.automationDecisionRoot,
|
||||||
|
normalized.automationApplyRoot,
|
||||||
|
];
|
||||||
|
for (let i = 0; i < roots.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < roots.length; j += 1) {
|
||||||
|
if (overlaps(roots[i]!, roots[j]!) || overlaps(roots[j]!, roots[i]!)) {
|
||||||
|
fail('authority roots overlap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const candidate of [
|
||||||
|
normalized.ownerPepperKeyringDirectory,
|
||||||
|
normalized.credentialFilePath,
|
||||||
|
]) {
|
||||||
|
const relative = path.relative(normalized.deploymentRoot, candidate);
|
||||||
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
fail('authentication material must be below deploymentRoot');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
roots.some(
|
||||||
|
(root) =>
|
||||||
|
overlaps(root, normalized.targetDatabasePath) ||
|
||||||
|
overlaps(normalized.targetDatabasePath, root),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail('targetDatabasePath overlaps an authority root');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
selected.busyTimeoutMs !== undefined &&
|
||||||
|
(!Number.isSafeInteger(selected.busyTimeoutMs) ||
|
||||||
|
(selected.busyTimeoutMs as number) < 1 ||
|
||||||
|
(selected.busyTimeoutMs as number) > 60_000)
|
||||||
|
) {
|
||||||
|
fail('busyTimeoutMs is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...normalized,
|
||||||
|
allowRootService: selected.allowRootService as boolean,
|
||||||
|
...(selected.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: selected.busyTimeoutMs as number }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
options: options(selected.options),
|
||||||
|
request: record(selected.request, 'request'),
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function id(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 id(value, DIGEST, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationAutomationApplyCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.automation.apply',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'appliedAtMs',
|
||||||
|
'automationId',
|
||||||
|
'decisionId',
|
||||||
|
'expectedDecisionDigest',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'mutationId',
|
||||||
|
'requestId',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.appliedAtMs) ||
|
||||||
|
(selected.request.appliedAtMs as number) < 0 ||
|
||||||
|
typeof selected.request.requestId !== 'string' ||
|
||||||
|
!SAFE_ID.test(selected.request.requestId)
|
||||||
|
)
|
||||||
|
fail('apply request timestamp or requestId is invalid');
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
|
||||||
|
automationId: id(selected.request.automationId, UUID_V4, 'automationId'),
|
||||||
|
expectedDecisionDigest: digest(
|
||||||
|
selected.request.expectedDecisionDigest,
|
||||||
|
'expectedDecisionDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
mutationId: id(selected.request.mutationId, UUID_V4, 'mutationId'),
|
||||||
|
requestId: selected.request.requestId,
|
||||||
|
appliedAtMs: selected.request.appliedAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationAutomationApplyVerifyCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyVerifyCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.automation.apply.verify',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
['automationId', 'decisionId', 'expectedApplyDigest'],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply.verify',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
|
||||||
|
automationId: id(selected.request.automationId, UUID_V4, 'automationId'),
|
||||||
|
expectedApplyDigest: digest(
|
||||||
|
selected.request.expectedApplyDigest,
|
||||||
|
'expectedApplyDigest',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationAutomationApplyRollbackCommand(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyRollbackCommand> {
|
||||||
|
const selected = command(
|
||||||
|
value,
|
||||||
|
'local.deployment.reconciliation.automation.apply.rollback',
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
selected.request,
|
||||||
|
[
|
||||||
|
'automationId',
|
||||||
|
'decisionId',
|
||||||
|
'expectedApplyDigest',
|
||||||
|
'expectedHeadDigest',
|
||||||
|
'rolledBackAtMs',
|
||||||
|
],
|
||||||
|
'request',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(selected.request.rolledBackAtMs) ||
|
||||||
|
(selected.request.rolledBackAtMs as number) < 0
|
||||||
|
)
|
||||||
|
fail('rolledBackAtMs is invalid');
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply.rollback',
|
||||||
|
options: selected.options,
|
||||||
|
request: Object.freeze({
|
||||||
|
decisionId: id(selected.request.decisionId, UUID_V7, 'decisionId'),
|
||||||
|
automationId: id(selected.request.automationId, UUID_V4, 'automationId'),
|
||||||
|
expectedApplyDigest: digest(
|
||||||
|
selected.request.expectedApplyDigest,
|
||||||
|
'expectedApplyDigest',
|
||||||
|
),
|
||||||
|
expectedHeadDigest: digest(
|
||||||
|
selected.request.expectedHeadDigest,
|
||||||
|
'expectedHeadDigest',
|
||||||
|
),
|
||||||
|
rolledBackAtMs: selected.request.rolledBackAtMs as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
+927
@@ -0,0 +1,927 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { applyReconciliationAutomationDecision } from '@qinglong/local-admin/reconciliation-automation-decision';
|
||||||
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
|
import {
|
||||||
|
establishAuthenticatedLocalCommand,
|
||||||
|
type AuthenticatedLocalCommand,
|
||||||
|
} from '@qinglong/local-owner-console/authenticated-command';
|
||||||
|
import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read';
|
||||||
|
import {
|
||||||
|
createLocalSqliteRolloutBackup,
|
||||||
|
inspectLocalSqliteSnapshot,
|
||||||
|
restoreLocalSqliteSnapshot,
|
||||||
|
} from '@qinglong/local-sqlite/rollout-safety';
|
||||||
|
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||||
|
|
||||||
|
import { currentIdentity } from '../../../foundation/contract';
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
import {
|
||||||
|
ensurePrivateDirectory,
|
||||||
|
preflightPublishedFile,
|
||||||
|
publishExactFile,
|
||||||
|
validatePrivateDirectory,
|
||||||
|
} from '../../../foundation/files';
|
||||||
|
import {
|
||||||
|
advanceLocalCutoverInstanceHead,
|
||||||
|
readLocalCutoverInstanceHead,
|
||||||
|
type LocalCutoverInstanceHead,
|
||||||
|
} from '../../../cutover/instanceLineage';
|
||||||
|
import {
|
||||||
|
inspectLocalReconciliationSealedBundle,
|
||||||
|
withLocalReconciliationSealedDatabaseAsync,
|
||||||
|
} from '../../sealed-bundle/reader';
|
||||||
|
import { readLocalReconciliationCaptureIntent } from '../../preparation';
|
||||||
|
import { proveLocalReconciliationStoppedState } from '../../stoppedProof';
|
||||||
|
import { LocalReconciliationReviewIssuerKeyringFileProvider } from '../../review/issuerKeyring';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationAutomationApplyCommand,
|
||||||
|
normalizeLocalReconciliationAutomationApplyRollbackCommand,
|
||||||
|
normalizeLocalReconciliationAutomationApplyVerifyCommand,
|
||||||
|
type LocalReconciliationAutomationApplyCommand,
|
||||||
|
type LocalReconciliationAutomationApplyOptions,
|
||||||
|
type LocalReconciliationAutomationApplyResult,
|
||||||
|
} from './applyContract';
|
||||||
|
import {
|
||||||
|
buildLocalReconciliationAutomationApplyIntent,
|
||||||
|
buildLocalReconciliationAutomationApplyReceipt,
|
||||||
|
buildLocalReconciliationAutomationRollbackReceipt,
|
||||||
|
localReconciliationAutomationApplyEvidenceContents,
|
||||||
|
normalizeLocalReconciliationAutomationApplyIntent,
|
||||||
|
normalizeLocalReconciliationAutomationApplyReceipt,
|
||||||
|
normalizeLocalReconciliationAutomationRollbackReceipt,
|
||||||
|
type LocalReconciliationAutomationApplyIntent,
|
||||||
|
type LocalReconciliationAutomationApplyReceipt,
|
||||||
|
} from './applyEvidence';
|
||||||
|
import { readLocalReconciliationAutomationDecisionTerminal } from './decisionCoordinator';
|
||||||
|
import { createLocalReconciliationAutomationRequirementFactory } from './planReader';
|
||||||
|
|
||||||
|
const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000;
|
||||||
|
|
||||||
|
interface ApplyPaths {
|
||||||
|
root: string;
|
||||||
|
backupRoot: string;
|
||||||
|
intent: string;
|
||||||
|
backup: string;
|
||||||
|
receipt: string;
|
||||||
|
rollback: string;
|
||||||
|
restoreStage: string;
|
||||||
|
replaced: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthenticationDatabase = Awaited<
|
||||||
|
ReturnType<typeof openLocalSqliteAuthenticationReadDatabase>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyDependencies {
|
||||||
|
readonly openAuthenticationDatabase?: typeof openLocalSqliteAuthenticationReadDatabase;
|
||||||
|
readonly authenticate?: typeof establishAuthenticatedLocalCommand;
|
||||||
|
readonly createBackup?: typeof createLocalSqliteRolloutBackup;
|
||||||
|
readonly inspectSnapshot?: typeof inspectLocalSqliteSnapshot;
|
||||||
|
readonly restoreSnapshot?: typeof restoreLocalSqliteSnapshot;
|
||||||
|
readonly afterBackupPublished?: () => void;
|
||||||
|
readonly afterPreparedHead?: () => void;
|
||||||
|
readonly afterDatabaseCommit?: () => void;
|
||||||
|
readonly afterReceiptPublished?: () => void;
|
||||||
|
readonly afterAppliedHead?: () => void;
|
||||||
|
readonly afterRestore?: () => void;
|
||||||
|
readonly afterRollbackReceipt?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string, cause?: unknown): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation automation apply ${message}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paths(root: string, automationId: string): Readonly<ApplyPaths> {
|
||||||
|
const selected = path.join(root, automationId);
|
||||||
|
const backupRoot = path.join(selected, 'backup');
|
||||||
|
return Object.freeze({
|
||||||
|
root: selected,
|
||||||
|
backupRoot,
|
||||||
|
intent: path.join(selected, 'intent.json'),
|
||||||
|
backup: path.join(backupRoot, 'before.sqlite'),
|
||||||
|
receipt: path.join(selected, 'receipt.json'),
|
||||||
|
rollback: path.join(selected, 'rollback.json'),
|
||||||
|
restoreStage: path.join(backupRoot, 'restore-stage.sqlite'),
|
||||||
|
replaced: path.join(backupRoot, 'replaced.sqlite'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionOptions(
|
||||||
|
options: Readonly<LocalReconciliationAutomationApplyOptions>,
|
||||||
|
) {
|
||||||
|
return Object.freeze({
|
||||||
|
deploymentRoot: options.deploymentRoot,
|
||||||
|
applicationRoot: options.applicationRoot,
|
||||||
|
automationRoot: options.automationRoot,
|
||||||
|
automationDecisionRoot: options.automationDecisionRoot,
|
||||||
|
allowRootService: options.allowRootService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIntent(
|
||||||
|
selected: Readonly<ApplyPaths>,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyIntent> {
|
||||||
|
return normalizeLocalReconciliationAutomationApplyIntent(
|
||||||
|
readPrivateLocalCommandFile(selected.intent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReceipt(
|
||||||
|
selected: Readonly<ApplyPaths>,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyReceipt> {
|
||||||
|
return normalizeLocalReconciliationAutomationApplyReceipt(
|
||||||
|
readPrivateLocalCommandFile(selected.receipt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCatalog(selected: Readonly<ApplyPaths>): void {
|
||||||
|
const allowed = new Set([
|
||||||
|
'backup',
|
||||||
|
'intent.json',
|
||||||
|
'receipt.json',
|
||||||
|
'rollback.json',
|
||||||
|
'.intent.json.ql3-deploy-stage',
|
||||||
|
'.receipt.json.ql3-deploy-stage',
|
||||||
|
'.rollback.json.ql3-deploy-stage',
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||||
|
if (!allowed.has(entry.name) || entry.isSymbolicLink())
|
||||||
|
fail('apply root contains unknown material');
|
||||||
|
}
|
||||||
|
const backupAllowed = new Set([
|
||||||
|
'before.sqlite',
|
||||||
|
'.before.sqlite.ql3-backup-stage',
|
||||||
|
'restore-stage.sqlite',
|
||||||
|
'replaced.sqlite',
|
||||||
|
]);
|
||||||
|
for (const entry of fs.readdirSync(selected.backupRoot, {
|
||||||
|
withFileTypes: true,
|
||||||
|
})) {
|
||||||
|
if (!backupAllowed.has(entry.name) || entry.isSymbolicLink())
|
||||||
|
fail('backup root contains unknown material');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance(
|
||||||
|
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||||
|
uid: number,
|
||||||
|
state:
|
||||||
|
| 'reconciliation_automation_apply_prepared'
|
||||||
|
| 'reconciliation_automation_applied'
|
||||||
|
| 'reconciliation_automation_rolled_back',
|
||||||
|
digest: string,
|
||||||
|
atMs: number,
|
||||||
|
): Readonly<LocalCutoverInstanceHead> {
|
||||||
|
return advanceLocalCutoverInstanceHead(
|
||||||
|
{
|
||||||
|
options: { deploymentRoot: intent.command.options.deploymentRoot },
|
||||||
|
request: {
|
||||||
|
cutoverId: intent.cutoverId,
|
||||||
|
profile: intent.profile,
|
||||||
|
instanceId: intent.instanceId,
|
||||||
|
expectedActivationDigest: intent.activationDigest,
|
||||||
|
requestedAtMs: atMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uid,
|
||||||
|
state,
|
||||||
|
intent.generation,
|
||||||
|
digest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(
|
||||||
|
operation: LocalReconciliationAutomationApplyResult['operation'],
|
||||||
|
status: LocalReconciliationAutomationApplyResult['status'],
|
||||||
|
receipt: Readonly<LocalReconciliationAutomationApplyReceipt>,
|
||||||
|
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyResult> {
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
state: head.state as
|
||||||
|
| 'reconciliation_automation_applied'
|
||||||
|
| 'reconciliation_automation_rolled_back',
|
||||||
|
decisionId: receipt.decisionId,
|
||||||
|
automationId: receipt.automationId,
|
||||||
|
applyDigest: receipt.applyDigest,
|
||||||
|
publicationDigest: receipt.publicationDigest,
|
||||||
|
adoptedTaskCount: receipt.adoptedTaskCount,
|
||||||
|
adoptedTriggerCount: receipt.adoptedTriggerCount,
|
||||||
|
backupSha256: intent.backup.sha256,
|
||||||
|
instanceHeadDigest: head.headDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReviewer(
|
||||||
|
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||||
|
reviewer: Readonly<SecurityPrincipal>,
|
||||||
|
atMs: number,
|
||||||
|
): void {
|
||||||
|
const principal = authenticated.principal;
|
||||||
|
if (
|
||||||
|
reviewer.subject.type !== 'user' ||
|
||||||
|
principal.subject.type !== 'user' ||
|
||||||
|
reviewer.subject.id !== principal.subject.id ||
|
||||||
|
principal.assurance !== 'local_console' ||
|
||||||
|
principal.authenticatedAtMs > atMs ||
|
||||||
|
atMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS ||
|
||||||
|
principal.expiresAtMs <= atMs
|
||||||
|
)
|
||||||
|
fail('current reviewer authentication is not strong or identical');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(
|
||||||
|
options: Readonly<LocalReconciliationAutomationApplyOptions>,
|
||||||
|
atMs: number,
|
||||||
|
profile: 'edge' | 'standalone',
|
||||||
|
reviewer: Readonly<SecurityPrincipal>,
|
||||||
|
dependencies: LocalReconciliationAutomationApplyDependencies,
|
||||||
|
): Promise<
|
||||||
|
Readonly<{
|
||||||
|
authenticated: Readonly<AuthenticatedLocalCommand>;
|
||||||
|
database: AuthenticationDatabase;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const open =
|
||||||
|
dependencies.openAuthenticationDatabase ??
|
||||||
|
openLocalSqliteAuthenticationReadDatabase;
|
||||||
|
const database = await open({
|
||||||
|
databasePath: options.targetDatabasePath,
|
||||||
|
profile,
|
||||||
|
...(options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: options.busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const selected = await (
|
||||||
|
dependencies.authenticate ?? establishAuthenticatedLocalCommand
|
||||||
|
)(database, {
|
||||||
|
deploymentRoot: options.deploymentRoot,
|
||||||
|
databasePath: options.targetDatabasePath,
|
||||||
|
ownerPepperKeyringDirectory: options.ownerPepperKeyringDirectory,
|
||||||
|
credentialFilePath: options.credentialFilePath,
|
||||||
|
authenticationNamespace: 'local_reconciliation_automation_apply',
|
||||||
|
now: () => atMs,
|
||||||
|
});
|
||||||
|
assertReviewer(selected, reviewer, atMs);
|
||||||
|
return Object.freeze({ authenticated: selected, database });
|
||||||
|
} catch (error) {
|
||||||
|
await database.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactBundle(
|
||||||
|
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||||
|
captureRoot: string,
|
||||||
|
captureId: string,
|
||||||
|
expectedBundleDigest: string,
|
||||||
|
expectedFingerprintDigest: string,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
|
const current = inspectLocalReconciliationSealedBundle(
|
||||||
|
captureRoot,
|
||||||
|
captureId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
current.receipt.bundleDigest !== expectedBundleDigest ||
|
||||||
|
current.fingerprintDigest !== expectedFingerprintDigest ||
|
||||||
|
intent.command.request.expectedDecisionDigest.length !== 64
|
||||||
|
)
|
||||||
|
fail('sealed source authority drifted');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyIntentCommand(
|
||||||
|
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||||
|
command: Readonly<LocalReconciliationAutomationApplyCommand>,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
intent.command.request.automationId !== command.request.automationId ||
|
||||||
|
intent.command.request.decisionId !== command.request.decisionId ||
|
||||||
|
intent.command.request.expectedDecisionDigest !==
|
||||||
|
command.request.expectedDecisionDigest ||
|
||||||
|
intent.command.request.mutationId !== command.request.mutationId ||
|
||||||
|
intent.command.request.requestId !== command.request.requestId ||
|
||||||
|
intent.command.request.appliedAtMs !== command.request.appliedAtMs ||
|
||||||
|
intent.command.request.expectedHeadDigest !==
|
||||||
|
command.request.expectedHeadDigest ||
|
||||||
|
JSON.stringify(intent.command.options) !== JSON.stringify(command.options)
|
||||||
|
)
|
||||||
|
fail('apply command is not an exact replay');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyLocalReconciliationAutomation(
|
||||||
|
value: unknown,
|
||||||
|
dependencies: LocalReconciliationAutomationApplyDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
|
||||||
|
const command = normalizeLocalReconciliationAutomationApplyCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
for (const [directory, label] of [
|
||||||
|
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||||
|
[command.options.applicationRoot, 'applicationRoot'],
|
||||||
|
[command.options.automationRoot, 'automationRoot'],
|
||||||
|
[command.options.automationDecisionRoot, 'automationDecisionRoot'],
|
||||||
|
[command.options.automationApplyRoot, 'automationApplyRoot'],
|
||||||
|
] as const)
|
||||||
|
validatePrivateDirectory(directory, uid, label);
|
||||||
|
const selected = paths(
|
||||||
|
command.options.automationApplyRoot,
|
||||||
|
command.request.automationId,
|
||||||
|
);
|
||||||
|
ensurePrivateDirectory(selected.root, uid, 'automation apply root');
|
||||||
|
ensurePrivateDirectory(
|
||||||
|
selected.backupRoot,
|
||||||
|
uid,
|
||||||
|
'automation apply backup root',
|
||||||
|
);
|
||||||
|
validateCatalog(selected);
|
||||||
|
|
||||||
|
const terminal = await readLocalReconciliationAutomationDecisionTerminal(
|
||||||
|
decisionOptions(command.options),
|
||||||
|
command.request.automationId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
terminal.receipt.decisionId !== command.request.decisionId ||
|
||||||
|
terminal.receipt.decisionDigest !== command.request.expectedDecisionDigest
|
||||||
|
)
|
||||||
|
fail('apply command is detached from signed decision');
|
||||||
|
const capture = readLocalReconciliationCaptureIntent(
|
||||||
|
terminal.plan.intent.command.options.captureRoot,
|
||||||
|
terminal.plan.intent.command.request.captureId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
capture.command.request.targetDatabasePath !==
|
||||||
|
command.options.targetDatabasePath
|
||||||
|
) {
|
||||||
|
fail('target database is detached from stopped capture');
|
||||||
|
}
|
||||||
|
|
||||||
|
let intent: Readonly<LocalReconciliationAutomationApplyIntent>;
|
||||||
|
const recoveringPreparedIntent = fs.existsSync(selected.intent);
|
||||||
|
let head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
terminal.intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(selected.intent)) {
|
||||||
|
intent = readIntent(selected);
|
||||||
|
verifyIntentCommand(intent, command);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_automation_reviewed' ||
|
||||||
|
head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
|
head.sourceRecordDigest !== terminal.receipt.decisionDigest
|
||||||
|
)
|
||||||
|
fail('apply lost reviewed head compare-and-swap');
|
||||||
|
const before = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||||
|
const backup = await (
|
||||||
|
dependencies.createBackup ?? createLocalSqliteRolloutBackup
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
backupPath: selected.backup,
|
||||||
|
profile: terminal.intent.profile,
|
||||||
|
...(command.options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
const after = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||||
|
if (after.proofDigest !== before.proofDigest)
|
||||||
|
fail('stopped target drifted across backup');
|
||||||
|
dependencies.afterBackupPublished?.();
|
||||||
|
intent = buildLocalReconciliationAutomationApplyIntent({
|
||||||
|
command,
|
||||||
|
instanceId: terminal.intent.instanceId,
|
||||||
|
cutoverId: terminal.intent.cutoverId,
|
||||||
|
activationDigest: terminal.intent.activationDigest,
|
||||||
|
profile: terminal.intent.profile,
|
||||||
|
projectId: terminal.intent.projectId,
|
||||||
|
generation: terminal.intent.generation,
|
||||||
|
stoppedProofDigest: before.proofDigest,
|
||||||
|
backup,
|
||||||
|
});
|
||||||
|
publishExactFile(
|
||||||
|
selected.intent,
|
||||||
|
localReconciliationAutomationApplyEvidenceContents(intent),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'automation apply intent',
|
||||||
|
);
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_automation_apply_prepared',
|
||||||
|
intent.preparationDigest,
|
||||||
|
command.request.appliedAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterPreparedHead?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(selected.receipt)) {
|
||||||
|
const receipt = readReceipt(selected);
|
||||||
|
const current = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 !== receipt.targetAfter.sha256)
|
||||||
|
fail('terminal apply target drifted');
|
||||||
|
if (
|
||||||
|
head.state === 'reconciliation_automation_apply_prepared' &&
|
||||||
|
head.sourceRecordDigest === intent.preparationDigest
|
||||||
|
) {
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_automation_applied',
|
||||||
|
receipt.applyDigest,
|
||||||
|
receipt.appliedAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterAppliedHead?.();
|
||||||
|
} else if (
|
||||||
|
head.state !== 'reconciliation_automation_applied' ||
|
||||||
|
receipt.applyDigest !== head.sourceRecordDigest
|
||||||
|
)
|
||||||
|
fail('terminal apply receipt drifted');
|
||||||
|
return result(command.operation, 'existing', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_automation_apply_prepared' ||
|
||||||
|
head.sourceRecordDigest !== intent.preparationDigest
|
||||||
|
)
|
||||||
|
fail('apply lost prepared head compare-and-swap');
|
||||||
|
if (!recoveringPreparedIntent) {
|
||||||
|
const stopped = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||||
|
if (stopped.proofDigest !== intent.stoppedProofDigest)
|
||||||
|
fail('stopped proof drifted before write');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedScope = await authenticate(
|
||||||
|
command.options,
|
||||||
|
command.request.appliedAtMs,
|
||||||
|
intent.profile,
|
||||||
|
terminal.reviewer,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
let publication;
|
||||||
|
try {
|
||||||
|
const openRequirements =
|
||||||
|
createLocalReconciliationAutomationRequirementFactory(
|
||||||
|
terminal.context.automation.planPath,
|
||||||
|
terminal.context.automation.receipt,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
|
||||||
|
terminal.context.application.intent.command.options.issuerKeyringPath,
|
||||||
|
);
|
||||||
|
const applied = await withLocalReconciliationSealedDatabaseAsync(
|
||||||
|
terminal.plan.bundle,
|
||||||
|
'legacy',
|
||||||
|
uid,
|
||||||
|
{},
|
||||||
|
async (sourceClient) =>
|
||||||
|
applyReconciliationAutomationDecision({
|
||||||
|
authorizationPath: terminal.authorizationPath,
|
||||||
|
decisionId: command.request.decisionId,
|
||||||
|
profile: intent.profile,
|
||||||
|
automationPlanDigest: terminal.receipt.automationPlanDigest,
|
||||||
|
inventoryDigest: terminal.receipt.legacyInventoryDigest,
|
||||||
|
sourceClient,
|
||||||
|
timezone: terminal.intent.legacyTimezone,
|
||||||
|
keyProvider,
|
||||||
|
observedAtMs: command.request.appliedAtMs,
|
||||||
|
openRequirements,
|
||||||
|
allowedModes: [0o400],
|
||||||
|
allowedParentModes: [0o500],
|
||||||
|
targetPath: command.options.targetDatabasePath,
|
||||||
|
projectId: intent.projectId,
|
||||||
|
mutationId: command.request.mutationId,
|
||||||
|
requestId: command.request.requestId,
|
||||||
|
confirmSourceIdentity() {
|
||||||
|
exactBundle(
|
||||||
|
intent,
|
||||||
|
terminal.plan.bundle.captureRoot,
|
||||||
|
terminal.plan.receipt.captureId,
|
||||||
|
terminal.plan.bundle.receipt.bundleDigest,
|
||||||
|
terminal.plan.bundle.fingerprintDigest,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const currentHead = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
currentHead.state !==
|
||||||
|
'reconciliation_automation_apply_prepared' ||
|
||||||
|
currentHead.sourceRecordDigest !== intent.preparationDigest
|
||||||
|
)
|
||||||
|
fail('apply lost write head authority');
|
||||||
|
},
|
||||||
|
async confirmReviewerAuthority(reviewer) {
|
||||||
|
assertReviewer(
|
||||||
|
authenticatedScope.authenticated,
|
||||||
|
reviewer,
|
||||||
|
command.request.appliedAtMs,
|
||||||
|
);
|
||||||
|
await authenticatedScope.authenticated.confirm();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (applied === null) fail('sealed Legacy source requires manual handling');
|
||||||
|
publication = applied;
|
||||||
|
} finally {
|
||||||
|
await authenticatedScope.database.close();
|
||||||
|
}
|
||||||
|
dependencies.afterDatabaseCommit?.();
|
||||||
|
const targetAfter = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
const receipt = buildLocalReconciliationAutomationApplyReceipt({
|
||||||
|
decisionId: command.request.decisionId,
|
||||||
|
automationId: command.request.automationId,
|
||||||
|
mutationId: command.request.mutationId,
|
||||||
|
preparationDigest: intent.preparationDigest,
|
||||||
|
preparedHeadDigest: head.headDigest,
|
||||||
|
publicationDigest: publication.adoption.publicationDigest,
|
||||||
|
adoptedTaskCount: publication.adoption.adoptedTaskCount,
|
||||||
|
adoptedTriggerCount: publication.adoption.adoptedTriggerCount,
|
||||||
|
skippedCount: publication.adoption.skippedCount,
|
||||||
|
targetAfter,
|
||||||
|
appliedAtMs: command.request.appliedAtMs,
|
||||||
|
});
|
||||||
|
preflightPublishedFile(
|
||||||
|
selected.receipt,
|
||||||
|
localReconciliationAutomationApplyEvidenceContents(receipt),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'automation apply receipt',
|
||||||
|
);
|
||||||
|
publishExactFile(
|
||||||
|
selected.receipt,
|
||||||
|
localReconciliationAutomationApplyEvidenceContents(receipt),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'automation apply receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterReceiptPublished?.();
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_automation_applied',
|
||||||
|
receipt.applyDigest,
|
||||||
|
command.request.appliedAtMs,
|
||||||
|
);
|
||||||
|
dependencies.afterAppliedHead?.();
|
||||||
|
return result(
|
||||||
|
command.operation,
|
||||||
|
publication.status === 'existing' ? 'existing' : 'applied',
|
||||||
|
receipt,
|
||||||
|
intent,
|
||||||
|
head,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalReconciliationAutomationApply(
|
||||||
|
value: unknown,
|
||||||
|
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
|
||||||
|
const command =
|
||||||
|
normalizeLocalReconciliationAutomationApplyVerifyCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
const selected = paths(
|
||||||
|
command.options.automationApplyRoot,
|
||||||
|
command.request.automationId,
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(
|
||||||
|
command.options.automationApplyRoot,
|
||||||
|
uid,
|
||||||
|
'automationApplyRoot',
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(selected.root, uid, 'automation apply root');
|
||||||
|
validatePrivateDirectory(
|
||||||
|
selected.backupRoot,
|
||||||
|
uid,
|
||||||
|
'automation apply backup root',
|
||||||
|
);
|
||||||
|
validateCatalog(selected);
|
||||||
|
const intent = readIntent(selected);
|
||||||
|
const receipt = readReceipt(selected);
|
||||||
|
if (
|
||||||
|
intent.command.request.decisionId !== command.request.decisionId ||
|
||||||
|
receipt.decisionId !== command.request.decisionId ||
|
||||||
|
receipt.automationId !== command.request.automationId ||
|
||||||
|
receipt.applyDigest !== command.request.expectedApplyDigest
|
||||||
|
)
|
||||||
|
fail('verify command is detached from apply receipt');
|
||||||
|
await readLocalReconciliationAutomationDecisionTerminal(
|
||||||
|
decisionOptions(command.options),
|
||||||
|
command.request.automationId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (head.state === 'reconciliation_automation_applied') {
|
||||||
|
if (head.sourceRecordDigest !== receipt.applyDigest)
|
||||||
|
fail('applied head drifted');
|
||||||
|
const current = await inspectLocalSqliteSnapshot({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 !== receipt.targetAfter.sha256)
|
||||||
|
fail('applied target drifted');
|
||||||
|
return result(command.operation, 'verified', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state === 'reconciliation_automation_rolled_back' &&
|
||||||
|
fs.existsSync(selected.rollback)
|
||||||
|
) {
|
||||||
|
const rollback = normalizeLocalReconciliationAutomationRollbackReceipt(
|
||||||
|
readPrivateLocalCommandFile(selected.rollback),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
rollback.applyDigest !== receipt.applyDigest ||
|
||||||
|
head.sourceRecordDigest !== rollback.rollbackDigest
|
||||||
|
)
|
||||||
|
fail('rollback head drifted');
|
||||||
|
const current = await inspectLocalSqliteSnapshot({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
current.sha256 !== intent.backup.sha256 ||
|
||||||
|
current.sha256 !== rollback.restored.sha256
|
||||||
|
)
|
||||||
|
fail('rolled-back target drifted');
|
||||||
|
return result(command.operation, 'verified', receipt, intent, head);
|
||||||
|
}
|
||||||
|
return fail('apply head is not terminal');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackLocalReconciliationAutomationApply(
|
||||||
|
value: unknown,
|
||||||
|
dependencies: LocalReconciliationAutomationApplyDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
|
||||||
|
const command =
|
||||||
|
normalizeLocalReconciliationAutomationApplyRollbackCommand(value);
|
||||||
|
const uid = currentIdentity().uid;
|
||||||
|
const selected = paths(
|
||||||
|
command.options.automationApplyRoot,
|
||||||
|
command.request.automationId,
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(
|
||||||
|
command.options.automationApplyRoot,
|
||||||
|
uid,
|
||||||
|
'automationApplyRoot',
|
||||||
|
);
|
||||||
|
validatePrivateDirectory(selected.root, uid, 'automation apply root');
|
||||||
|
validatePrivateDirectory(
|
||||||
|
selected.backupRoot,
|
||||||
|
uid,
|
||||||
|
'automation apply backup root',
|
||||||
|
);
|
||||||
|
validateCatalog(selected);
|
||||||
|
const intent = readIntent(selected);
|
||||||
|
const receipt = readReceipt(selected);
|
||||||
|
if (
|
||||||
|
receipt.decisionId !== command.request.decisionId ||
|
||||||
|
receipt.automationId !== command.request.automationId ||
|
||||||
|
receipt.applyDigest !== command.request.expectedApplyDigest
|
||||||
|
)
|
||||||
|
fail('rollback command is detached from apply receipt');
|
||||||
|
const terminal = await readLocalReconciliationAutomationDecisionTerminal(
|
||||||
|
decisionOptions(command.options),
|
||||||
|
command.request.automationId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
let head = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(selected.rollback)) {
|
||||||
|
const rollback = normalizeLocalReconciliationAutomationRollbackReceipt(
|
||||||
|
readPrivateLocalCommandFile(selected.rollback),
|
||||||
|
);
|
||||||
|
const current = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 !== rollback.restored.sha256)
|
||||||
|
fail('rollback replay target drifted');
|
||||||
|
if (
|
||||||
|
head.state === 'reconciliation_automation_applied' &&
|
||||||
|
head.sourceRecordDigest === receipt.applyDigest
|
||||||
|
) {
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_automation_rolled_back',
|
||||||
|
rollback.rollbackDigest,
|
||||||
|
rollback.rolledBackAtMs,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
head.state !== 'reconciliation_automation_rolled_back' ||
|
||||||
|
head.sourceRecordDigest !== rollback.rollbackDigest
|
||||||
|
)
|
||||||
|
fail('rollback replay drifted');
|
||||||
|
return result(command.operation, 'existing', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
head.state !== 'reconciliation_automation_applied' ||
|
||||||
|
head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
|
head.sourceRecordDigest !== receipt.applyDigest
|
||||||
|
)
|
||||||
|
fail('rollback lost applied head compare-and-swap');
|
||||||
|
const current = await (
|
||||||
|
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
});
|
||||||
|
if (current.sha256 === intent.backup.sha256) {
|
||||||
|
const rollback = buildLocalReconciliationAutomationRollbackReceipt({
|
||||||
|
decisionId: receipt.decisionId,
|
||||||
|
automationId: receipt.automationId,
|
||||||
|
applyDigest: receipt.applyDigest,
|
||||||
|
restored: current,
|
||||||
|
rolledBackAtMs: command.request.rolledBackAtMs,
|
||||||
|
});
|
||||||
|
publishExactFile(
|
||||||
|
selected.rollback,
|
||||||
|
localReconciliationAutomationApplyEvidenceContents(rollback),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'automation rollback receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterRollbackReceipt?.();
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_automation_rolled_back',
|
||||||
|
rollback.rollbackDigest,
|
||||||
|
command.request.rolledBackAtMs,
|
||||||
|
);
|
||||||
|
return result(command.operation, 'existing', receipt, intent, head);
|
||||||
|
}
|
||||||
|
if (current.sha256 !== receipt.targetAfter.sha256)
|
||||||
|
fail('rollback current target drifted');
|
||||||
|
|
||||||
|
const authenticatedScope = await authenticate(
|
||||||
|
command.options,
|
||||||
|
command.request.rolledBackAtMs,
|
||||||
|
intent.profile,
|
||||||
|
terminal.reviewer,
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const openRequirements =
|
||||||
|
createLocalReconciliationAutomationRequirementFactory(
|
||||||
|
terminal.context.automation.planPath,
|
||||||
|
terminal.context.automation.receipt,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
|
||||||
|
terminal.context.application.intent.command.options.issuerKeyringPath,
|
||||||
|
);
|
||||||
|
const replay = await withLocalReconciliationSealedDatabaseAsync(
|
||||||
|
terminal.plan.bundle,
|
||||||
|
'legacy',
|
||||||
|
uid,
|
||||||
|
{},
|
||||||
|
async (sourceClient) =>
|
||||||
|
applyReconciliationAutomationDecision({
|
||||||
|
authorizationPath: terminal.authorizationPath,
|
||||||
|
decisionId: receipt.decisionId,
|
||||||
|
profile: intent.profile,
|
||||||
|
automationPlanDigest: terminal.receipt.automationPlanDigest,
|
||||||
|
inventoryDigest: terminal.receipt.legacyInventoryDigest,
|
||||||
|
sourceClient,
|
||||||
|
timezone: terminal.intent.legacyTimezone,
|
||||||
|
keyProvider,
|
||||||
|
observedAtMs: intent.command.request.appliedAtMs,
|
||||||
|
openRequirements,
|
||||||
|
allowedModes: [0o400],
|
||||||
|
allowedParentModes: [0o500],
|
||||||
|
targetPath: command.options.targetDatabasePath,
|
||||||
|
projectId: intent.projectId,
|
||||||
|
mutationId: intent.command.request.mutationId,
|
||||||
|
requestId: intent.command.request.requestId,
|
||||||
|
confirmSourceIdentity() {
|
||||||
|
const currentHead = readLocalCutoverInstanceHead(
|
||||||
|
command.options.deploymentRoot,
|
||||||
|
intent.instanceId,
|
||||||
|
uid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
currentHead.state !== 'reconciliation_automation_applied' ||
|
||||||
|
currentHead.sourceRecordDigest !== receipt.applyDigest
|
||||||
|
)
|
||||||
|
fail('rollback lost current policy fence');
|
||||||
|
},
|
||||||
|
async confirmReviewerAuthority(reviewer) {
|
||||||
|
assertReviewer(
|
||||||
|
authenticatedScope.authenticated,
|
||||||
|
reviewer,
|
||||||
|
command.request.rolledBackAtMs,
|
||||||
|
);
|
||||||
|
await authenticatedScope.authenticated.confirm();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
replay === null ||
|
||||||
|
replay.status !== 'existing' ||
|
||||||
|
replay.adoption.publicationDigest !== receipt.publicationDigest
|
||||||
|
)
|
||||||
|
fail('rollback could not verify current publication');
|
||||||
|
} finally {
|
||||||
|
await authenticatedScope.database.close();
|
||||||
|
}
|
||||||
|
const restored = await (
|
||||||
|
dependencies.restoreSnapshot ?? restoreLocalSqliteSnapshot
|
||||||
|
)({
|
||||||
|
databasePath: command.options.targetDatabasePath,
|
||||||
|
sourceSnapshotPath: selected.backup,
|
||||||
|
restoreStagePath: selected.restoreStage,
|
||||||
|
replacedDatabasePath: selected.replaced,
|
||||||
|
expectedCurrentSha256: receipt.targetAfter.sha256,
|
||||||
|
expectedSourceSha256: intent.backup.sha256,
|
||||||
|
preserveDatabaseIdentity: true,
|
||||||
|
profile: intent.profile,
|
||||||
|
...(command.options.busyTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||||
|
});
|
||||||
|
dependencies.afterRestore?.();
|
||||||
|
const rollback = buildLocalReconciliationAutomationRollbackReceipt({
|
||||||
|
decisionId: receipt.decisionId,
|
||||||
|
automationId: receipt.automationId,
|
||||||
|
applyDigest: receipt.applyDigest,
|
||||||
|
restored: Object.freeze({
|
||||||
|
contractVersion: restored.contractVersion,
|
||||||
|
sha256: restored.sha256,
|
||||||
|
bytes: restored.bytes,
|
||||||
|
pageCount: restored.pageCount,
|
||||||
|
pageSize: restored.pageSize,
|
||||||
|
}),
|
||||||
|
rolledBackAtMs: command.request.rolledBackAtMs,
|
||||||
|
});
|
||||||
|
publishExactFile(
|
||||||
|
selected.rollback,
|
||||||
|
localReconciliationAutomationApplyEvidenceContents(rollback),
|
||||||
|
0o600,
|
||||||
|
uid,
|
||||||
|
'automation rollback receipt',
|
||||||
|
);
|
||||||
|
dependencies.afterRollbackReceipt?.();
|
||||||
|
head = advance(
|
||||||
|
intent,
|
||||||
|
uid,
|
||||||
|
'reconciliation_automation_rolled_back',
|
||||||
|
rollback.rollbackDigest,
|
||||||
|
command.request.rolledBackAtMs,
|
||||||
|
);
|
||||||
|
return result(command.operation, 'rolled_back', receipt, intent, head);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyLocalReconciliationAutomationCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
|
||||||
|
return applyLocalReconciliationAutomation(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalReconciliationAutomationApplyCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
|
||||||
|
return verifyLocalReconciliationAutomationApply(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackLocalReconciliationAutomationApplyCommandFile(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Readonly<LocalReconciliationAutomationApplyResult>> {
|
||||||
|
return rollbackLocalReconciliationAutomationApply(
|
||||||
|
readPrivateLocalCommandFile(filePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
+351
@@ -0,0 +1,351 @@
|
|||||||
|
import type {
|
||||||
|
LocalSqliteRolloutBackupEvidence,
|
||||||
|
LocalSqliteSnapshotEvidence,
|
||||||
|
} from '@qinglong/local-sqlite/rollout-safety';
|
||||||
|
|
||||||
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
|
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||||
|
import {
|
||||||
|
normalizeLocalReconciliationAutomationApplyCommand,
|
||||||
|
type LocalReconciliationAutomationApplyCommand,
|
||||||
|
} from './applyContract';
|
||||||
|
|
||||||
|
const INTENT_SCHEMA = 'qinglong3-local-reconciliation-automation-apply-intent';
|
||||||
|
const RECEIPT_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-automation-apply-receipt';
|
||||||
|
const ROLLBACK_SCHEMA =
|
||||||
|
'qinglong3-local-reconciliation-automation-rollback-receipt';
|
||||||
|
const DIGEST = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyIntent {
|
||||||
|
readonly schema: typeof INTENT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly command: Readonly<LocalReconciliationAutomationApplyCommand>;
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly cutoverId: string;
|
||||||
|
readonly activationDigest: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly stoppedProofDigest: string;
|
||||||
|
readonly backup: Readonly<LocalSqliteRolloutBackupEvidence>;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationApplyReceipt {
|
||||||
|
readonly schema: typeof RECEIPT_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_automation_applied';
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly automationId: string;
|
||||||
|
readonly mutationId: string;
|
||||||
|
readonly preparationDigest: string;
|
||||||
|
readonly preparedHeadDigest: string;
|
||||||
|
readonly publicationDigest: string;
|
||||||
|
readonly adoptedTaskCount: number;
|
||||||
|
readonly adoptedTriggerCount: number;
|
||||||
|
readonly skippedCount: number;
|
||||||
|
readonly targetAfter: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
|
readonly appliedAtMs: number;
|
||||||
|
readonly applyDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationRollbackReceipt {
|
||||||
|
readonly schema: typeof ROLLBACK_SCHEMA;
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
readonly state: 'reconciliation_automation_rolled_back';
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly automationId: string;
|
||||||
|
readonly applyDigest: string;
|
||||||
|
readonly restored: Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
|
readonly rolledBackAtMs: number;
|
||||||
|
readonly rollbackDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new LocalDeploymentConfigurationError(
|
||||||
|
`reconciliation automation apply 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 record = value as Record<string, unknown>;
|
||||||
|
const actual = Object.keys(record).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
)
|
||||||
|
fail(`${label} shape is invalid`);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDigest(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && DIGEST.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): Readonly<LocalSqliteSnapshotEvidence> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
['bytes', 'contractVersion', 'pageCount', 'pageSize', 'sha256'],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!validDigest(selected.sha256) ||
|
||||||
|
![selected.bytes, selected.pageCount, selected.pageSize].every(
|
||||||
|
(item) => Number.isSafeInteger(item) && (item as number) > 0,
|
||||||
|
) ||
|
||||||
|
typeof selected.contractVersion !== 'number'
|
||||||
|
)
|
||||||
|
fail(`${label} is invalid`);
|
||||||
|
return Object.freeze({
|
||||||
|
contractVersion: selected.contractVersion,
|
||||||
|
sha256: selected.sha256,
|
||||||
|
bytes: selected.bytes,
|
||||||
|
pageCount: selected.pageCount,
|
||||||
|
pageSize: selected.pageSize,
|
||||||
|
}) as unknown as Readonly<LocalSqliteSnapshotEvidence>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backup(value: unknown): Readonly<LocalSqliteRolloutBackupEvidence> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'bytes',
|
||||||
|
'contractVersion',
|
||||||
|
'pageCount',
|
||||||
|
'pageSize',
|
||||||
|
'sha256',
|
||||||
|
'status',
|
||||||
|
'writeContractVersion',
|
||||||
|
],
|
||||||
|
'backup',
|
||||||
|
);
|
||||||
|
const base = snapshot(
|
||||||
|
{
|
||||||
|
bytes: selected.bytes,
|
||||||
|
contractVersion: selected.contractVersion,
|
||||||
|
pageCount: selected.pageCount,
|
||||||
|
pageSize: selected.pageSize,
|
||||||
|
sha256: selected.sha256,
|
||||||
|
},
|
||||||
|
'backup snapshot',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(selected.status !== 'prepared' && selected.status !== 'existing') ||
|
||||||
|
typeof selected.writeContractVersion !== 'number'
|
||||||
|
)
|
||||||
|
fail('backup is invalid');
|
||||||
|
return Object.freeze({
|
||||||
|
status: selected.status,
|
||||||
|
writeContractVersion: selected.writeContractVersion,
|
||||||
|
...base,
|
||||||
|
}) as Readonly<LocalSqliteRolloutBackupEvidence>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationAutomationApplyIntent(
|
||||||
|
input: Omit<
|
||||||
|
LocalReconciliationAutomationApplyIntent,
|
||||||
|
'schema' | 'schemaVersion' | 'preparationDigest'
|
||||||
|
>,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyIntent> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: INTENT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
preparationDigest: cutoverDigest(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationAutomationApplyIntent(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyIntent> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'activationDigest',
|
||||||
|
'backup',
|
||||||
|
'command',
|
||||||
|
'cutoverId',
|
||||||
|
'generation',
|
||||||
|
'instanceId',
|
||||||
|
'preparationDigest',
|
||||||
|
'profile',
|
||||||
|
'projectId',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'stoppedProofDigest',
|
||||||
|
],
|
||||||
|
'intent',
|
||||||
|
);
|
||||||
|
const { preparationDigest, ...raw } = selected;
|
||||||
|
const command = normalizeLocalReconciliationAutomationApplyCommand(
|
||||||
|
selected.command,
|
||||||
|
);
|
||||||
|
const normalized = Object.freeze({
|
||||||
|
...raw,
|
||||||
|
command,
|
||||||
|
backup: backup(selected.backup),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
selected.schema !== INTENT_SCHEMA ||
|
||||||
|
selected.schemaVersion !== 1 ||
|
||||||
|
!validDigest(preparationDigest) ||
|
||||||
|
cutoverDigest(raw) !== preparationDigest ||
|
||||||
|
cutoverDigest(normalized) !== preparationDigest ||
|
||||||
|
!validDigest(selected.activationDigest) ||
|
||||||
|
!validDigest(selected.stoppedProofDigest) ||
|
||||||
|
(selected.profile !== 'edge' && selected.profile !== 'standalone') ||
|
||||||
|
!Number.isSafeInteger(selected.generation) ||
|
||||||
|
(selected.generation as number) < 1 ||
|
||||||
|
![selected.instanceId, selected.cutoverId, selected.projectId].every(
|
||||||
|
(item) => typeof item === 'string' && item.length > 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fail('intent binding is invalid');
|
||||||
|
return Object.freeze({
|
||||||
|
...normalized,
|
||||||
|
preparationDigest,
|
||||||
|
}) as unknown as Readonly<LocalReconciliationAutomationApplyIntent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationAutomationApplyReceipt(
|
||||||
|
input: Omit<
|
||||||
|
LocalReconciliationAutomationApplyReceipt,
|
||||||
|
'schema' | 'schemaVersion' | 'state' | 'applyDigest'
|
||||||
|
>,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyReceipt> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: RECEIPT_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_automation_applied' as const,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, applyDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationAutomationApplyReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationAutomationApplyReceipt> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'adoptedTaskCount',
|
||||||
|
'adoptedTriggerCount',
|
||||||
|
'appliedAtMs',
|
||||||
|
'applyDigest',
|
||||||
|
'automationId',
|
||||||
|
'decisionId',
|
||||||
|
'mutationId',
|
||||||
|
'preparationDigest',
|
||||||
|
'preparedHeadDigest',
|
||||||
|
'publicationDigest',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'skippedCount',
|
||||||
|
'state',
|
||||||
|
'targetAfter',
|
||||||
|
],
|
||||||
|
'receipt',
|
||||||
|
);
|
||||||
|
const { applyDigest, ...payload } = selected;
|
||||||
|
const targetAfter = snapshot(selected.targetAfter, 'targetAfter');
|
||||||
|
if (
|
||||||
|
selected.schema !== RECEIPT_SCHEMA ||
|
||||||
|
selected.schemaVersion !== 1 ||
|
||||||
|
selected.state !== 'reconciliation_automation_applied' ||
|
||||||
|
!validDigest(applyDigest) ||
|
||||||
|
cutoverDigest(payload) !== applyDigest ||
|
||||||
|
![
|
||||||
|
selected.preparationDigest,
|
||||||
|
selected.preparedHeadDigest,
|
||||||
|
selected.publicationDigest,
|
||||||
|
].every(validDigest) ||
|
||||||
|
![
|
||||||
|
selected.adoptedTaskCount,
|
||||||
|
selected.adoptedTriggerCount,
|
||||||
|
selected.skippedCount,
|
||||||
|
selected.appliedAtMs,
|
||||||
|
].every((item) => Number.isSafeInteger(item) && (item as number) >= 0)
|
||||||
|
)
|
||||||
|
fail('receipt binding is invalid');
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
targetAfter,
|
||||||
|
applyDigest,
|
||||||
|
}) as unknown as Readonly<LocalReconciliationAutomationApplyReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalReconciliationAutomationRollbackReceipt(
|
||||||
|
input: Omit<
|
||||||
|
LocalReconciliationAutomationRollbackReceipt,
|
||||||
|
'schema' | 'schemaVersion' | 'state' | 'rollbackDigest'
|
||||||
|
>,
|
||||||
|
): Readonly<LocalReconciliationAutomationRollbackReceipt> {
|
||||||
|
const payload = Object.freeze({
|
||||||
|
schema: ROLLBACK_SCHEMA,
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
state: 'reconciliation_automation_rolled_back' as const,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return Object.freeze({ ...payload, rollbackDigest: cutoverDigest(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocalReconciliationAutomationRollbackReceipt(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationAutomationRollbackReceipt> {
|
||||||
|
const selected = exact(
|
||||||
|
value,
|
||||||
|
[
|
||||||
|
'applyDigest',
|
||||||
|
'automationId',
|
||||||
|
'decisionId',
|
||||||
|
'restored',
|
||||||
|
'rollbackDigest',
|
||||||
|
'rolledBackAtMs',
|
||||||
|
'schema',
|
||||||
|
'schemaVersion',
|
||||||
|
'state',
|
||||||
|
],
|
||||||
|
'rollback receipt',
|
||||||
|
);
|
||||||
|
const { rollbackDigest, ...payload } = selected;
|
||||||
|
const restored = snapshot(selected.restored, 'restored');
|
||||||
|
if (
|
||||||
|
selected.schema !== ROLLBACK_SCHEMA ||
|
||||||
|
selected.schemaVersion !== 1 ||
|
||||||
|
selected.state !== 'reconciliation_automation_rolled_back' ||
|
||||||
|
!validDigest(selected.applyDigest) ||
|
||||||
|
!validDigest(rollbackDigest) ||
|
||||||
|
cutoverDigest(payload) !== rollbackDigest ||
|
||||||
|
!Number.isSafeInteger(selected.rolledBackAtMs) ||
|
||||||
|
(selected.rolledBackAtMs as number) < 0
|
||||||
|
)
|
||||||
|
fail('rollback receipt binding is invalid');
|
||||||
|
return Object.freeze({
|
||||||
|
...payload,
|
||||||
|
restored,
|
||||||
|
rollbackDigest,
|
||||||
|
}) as unknown as Readonly<LocalReconciliationAutomationRollbackReceipt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localReconciliationAutomationApplyEvidenceContents(
|
||||||
|
value:
|
||||||
|
| LocalReconciliationAutomationApplyIntent
|
||||||
|
| LocalReconciliationAutomationApplyReceipt
|
||||||
|
| LocalReconciliationAutomationRollbackReceipt,
|
||||||
|
): string {
|
||||||
|
return `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
}
|
||||||
+92
-23
@@ -13,6 +13,7 @@ import {
|
|||||||
type AuthenticatedLocalCommand,
|
type AuthenticatedLocalCommand,
|
||||||
} from '@qinglong/local-owner-console/authenticated-command';
|
} from '@qinglong/local-owner-console/authenticated-command';
|
||||||
import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read';
|
import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read';
|
||||||
|
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||||
|
|
||||||
import { currentIdentity } from '../../../foundation/contract';
|
import { currentIdentity } from '../../../foundation/contract';
|
||||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
@@ -103,6 +104,15 @@ export interface LocalReconciliationAutomationDecisionDependencies
|
|||||||
readonly afterHeadAdvanced?: () => void;
|
readonly afterHeadAdvanced?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationAutomationDecisionTerminal {
|
||||||
|
readonly intent: Readonly<LocalReconciliationAutomationDecisionIntent>;
|
||||||
|
readonly receipt: Readonly<LocalReconciliationAutomationDecisionReceipt>;
|
||||||
|
readonly authorizationPath: string;
|
||||||
|
readonly context: Readonly<DecisionContext>;
|
||||||
|
readonly plan: ReturnType<typeof readLocalReconciliationPlanTerminal>;
|
||||||
|
readonly reviewer: Readonly<SecurityPrincipal>;
|
||||||
|
}
|
||||||
|
|
||||||
function configurationError(message: string, cause?: unknown): never {
|
function configurationError(message: string, cause?: unknown): never {
|
||||||
throw new LocalDeploymentConfigurationError(
|
throw new LocalDeploymentConfigurationError(
|
||||||
`reconciliation automation decision ${message}`,
|
`reconciliation automation decision ${message}`,
|
||||||
@@ -149,17 +159,17 @@ function validateDirectory(
|
|||||||
return mode;
|
return mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCatalog(selected: Readonly<DecisionPaths>, terminal: boolean): void {
|
function validateCatalog(
|
||||||
|
selected: Readonly<DecisionPaths>,
|
||||||
|
terminal: boolean,
|
||||||
|
): void {
|
||||||
const allowed = new Set([
|
const allowed = new Set([
|
||||||
'authorization.ndjson',
|
'authorization.ndjson',
|
||||||
'intent.json',
|
'intent.json',
|
||||||
'receipt.json',
|
'receipt.json',
|
||||||
'staging',
|
'staging',
|
||||||
...(!terminal
|
...(!terminal
|
||||||
? [
|
? ['.intent.json.ql3-deploy-stage', '.receipt.json.ql3-deploy-stage']
|
||||||
'.intent.json.ql3-deploy-stage',
|
|
||||||
'.receipt.json.ql3-deploy-stage',
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
]);
|
]);
|
||||||
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||||
@@ -242,7 +252,9 @@ function terminalJson(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function context(
|
async function context(
|
||||||
options: Readonly<LocalReconciliationAutomationDecisionPrepareCommand['options']>,
|
options: Readonly<
|
||||||
|
LocalReconciliationAutomationDecisionPrepareCommand['options']
|
||||||
|
>,
|
||||||
automationId: string,
|
automationId: string,
|
||||||
uid: number,
|
uid: number,
|
||||||
): Promise<Readonly<DecisionContext>> {
|
): Promise<Readonly<DecisionContext>> {
|
||||||
@@ -412,7 +424,7 @@ export async function prepareLocalReconciliationAutomationDecision(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
current.automation.receipt.automationPlanDigest !==
|
current.automation.receipt.automationPlanDigest !==
|
||||||
command.request.expectedAutomationPlanDigest
|
command.request.expectedAutomationPlanDigest
|
||||||
) {
|
) {
|
||||||
configurationError('expected automation plan digest drifted');
|
configurationError('expected automation plan digest drifted');
|
||||||
}
|
}
|
||||||
@@ -438,7 +450,8 @@ export async function prepareLocalReconciliationAutomationDecision(
|
|||||||
if (
|
if (
|
||||||
(head.state === 'reconciliation_automation_planned' &&
|
(head.state === 'reconciliation_automation_planned' &&
|
||||||
(head.headDigest !== command.request.expectedHeadDigest ||
|
(head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
head.sourceRecordDigest !== intent.command.request.expectedAutomationPlanDigest)) ||
|
head.sourceRecordDigest !==
|
||||||
|
intent.command.request.expectedAutomationPlanDigest)) ||
|
||||||
(head.state === 'reconciliation_automation_decision_prepared' &&
|
(head.state === 'reconciliation_automation_decision_prepared' &&
|
||||||
head.sourceRecordDigest !== intent.preparationDigest) ||
|
head.sourceRecordDigest !== intent.preparationDigest) ||
|
||||||
(head.state !== 'reconciliation_automation_planned' &&
|
(head.state !== 'reconciliation_automation_planned' &&
|
||||||
@@ -453,7 +466,8 @@ export async function prepareLocalReconciliationAutomationDecision(
|
|||||||
ensurePrivateDirectory(selected.root, uid, 'automationDecisionDirectory');
|
ensurePrivateDirectory(selected.root, uid, 'automationDecisionDirectory');
|
||||||
ensurePrivateDirectory(selected.staging, uid, 'automationDecisionStaging');
|
ensurePrivateDirectory(selected.staging, uid, 'automationDecisionStaging');
|
||||||
validateCatalog(selected, false);
|
validateCatalog(selected, false);
|
||||||
const contents = localReconciliationAutomationDecisionEvidenceContents(intent);
|
const contents =
|
||||||
|
localReconciliationAutomationDecisionEvidenceContents(intent);
|
||||||
preflightPublishedFile(
|
preflightPublishedFile(
|
||||||
selected.intent,
|
selected.intent,
|
||||||
contents,
|
contents,
|
||||||
@@ -505,7 +519,9 @@ function validateCommitBinding(
|
|||||||
|
|
||||||
function strongReviewer(
|
function strongReviewer(
|
||||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||||
original: Readonly<DecisionContext['application']['review']['authorization']['header']['reviewer']>,
|
original: Readonly<
|
||||||
|
DecisionContext['application']['review']['authorization']['header']['reviewer']
|
||||||
|
>,
|
||||||
committedAtMs: number,
|
committedAtMs: number,
|
||||||
) {
|
) {
|
||||||
const principal = authenticated.principal;
|
const principal = authenticated.principal;
|
||||||
@@ -598,15 +614,16 @@ async function authorization(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
capture.command.request.targetDatabasePath !==
|
capture.command.request.targetDatabasePath !==
|
||||||
command.options.targetDatabasePath
|
command.options.targetDatabasePath
|
||||||
) {
|
) {
|
||||||
configurationError('authentication database is detached from capture');
|
configurationError('authentication database is detached from capture');
|
||||||
}
|
}
|
||||||
const openRequirements = createLocalReconciliationAutomationRequirementFactory(
|
const openRequirements =
|
||||||
selected.automation.planPath,
|
createLocalReconciliationAutomationRequirementFactory(
|
||||||
selected.automation.receipt,
|
selected.automation.planPath,
|
||||||
uid,
|
selected.automation.receipt,
|
||||||
);
|
uid,
|
||||||
|
);
|
||||||
const keyringPath =
|
const keyringPath =
|
||||||
selected.application.intent.command.options.issuerKeyringPath;
|
selected.application.intent.command.options.issuerKeyringPath;
|
||||||
ensureLocalReconciliationReviewIssuerKeyring(keyringPath);
|
ensureLocalReconciliationReviewIssuerKeyring(keyringPath);
|
||||||
@@ -774,11 +791,12 @@ async function verifyPublication(
|
|||||||
allowedParentModes: readonly (0o500 | 0o700)[],
|
allowedParentModes: readonly (0o500 | 0o700)[],
|
||||||
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
|
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
|
||||||
const terminal = planTerminal(selected);
|
const terminal = planTerminal(selected);
|
||||||
const openRequirements = createLocalReconciliationAutomationRequirementFactory(
|
const openRequirements =
|
||||||
selected.automation.planPath,
|
createLocalReconciliationAutomationRequirementFactory(
|
||||||
selected.automation.receipt,
|
selected.automation.planPath,
|
||||||
uid,
|
selected.automation.receipt,
|
||||||
);
|
uid,
|
||||||
|
);
|
||||||
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
|
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
|
||||||
selected.application.intent.command.options.issuerKeyringPath,
|
selected.application.intent.command.options.issuerKeyringPath,
|
||||||
);
|
);
|
||||||
@@ -862,8 +880,7 @@ export async function commitLocalReconciliationAutomationDecision(
|
|||||||
receipt.preparedHeadDigest !== command.request.expectedHeadDigest ||
|
receipt.preparedHeadDigest !== command.request.expectedHeadDigest ||
|
||||||
receipt.issuedAtMs !== command.request.committedAtMs ||
|
receipt.issuedAtMs !== command.request.committedAtMs ||
|
||||||
receipt.expiresAtMs !==
|
receipt.expiresAtMs !==
|
||||||
command.request.committedAtMs +
|
command.request.committedAtMs + command.request.authorizationLifetimeMs
|
||||||
command.request.authorizationLifetimeMs
|
|
||||||
) {
|
) {
|
||||||
configurationError('terminal receipt is not an exact command replay');
|
configurationError('terminal receipt is not an exact command replay');
|
||||||
}
|
}
|
||||||
@@ -1013,6 +1030,58 @@ export async function verifyLocalReconciliationAutomationDecision(
|
|||||||
return terminalResult(command.operation, 'verified', receipt, head);
|
return terminalResult(command.operation, 'verified', receipt, head);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readLocalReconciliationAutomationDecisionTerminal(
|
||||||
|
options: Readonly<
|
||||||
|
LocalReconciliationAutomationDecisionPrepareCommand['options']
|
||||||
|
>,
|
||||||
|
automationId: string,
|
||||||
|
uid: number,
|
||||||
|
dependencies: LocalReconciliationAutomationDecisionDependencies = {},
|
||||||
|
): Promise<Readonly<LocalReconciliationAutomationDecisionTerminal>> {
|
||||||
|
for (const [directory, label] of [
|
||||||
|
[options.deploymentRoot, 'deploymentRoot'],
|
||||||
|
[options.applicationRoot, 'applicationRoot'],
|
||||||
|
[options.automationRoot, 'automationRoot'],
|
||||||
|
[options.automationDecisionRoot, 'automationDecisionRoot'],
|
||||||
|
] as const) {
|
||||||
|
validatePrivateDirectory(directory, uid, label);
|
||||||
|
}
|
||||||
|
const selectedPaths = paths(options.automationDecisionRoot, automationId);
|
||||||
|
validateDirectory(selectedPaths.root, uid, [0o500], 'decision root');
|
||||||
|
validateDirectory(selectedPaths.staging, uid, [0o500], 'decision staging');
|
||||||
|
validateCatalog(selectedPaths, true);
|
||||||
|
const intent = readIntent(selectedPaths, uid, [0o400]);
|
||||||
|
const receipt = readReceipt(selectedPaths, uid, [0o400]);
|
||||||
|
if (
|
||||||
|
intent.command.request.automationId !== automationId ||
|
||||||
|
receipt.automationId !== automationId ||
|
||||||
|
receipt.decisionId !== intent.command.request.decisionId
|
||||||
|
) {
|
||||||
|
configurationError('terminal decision identity drifted');
|
||||||
|
}
|
||||||
|
const selected = await context(options, automationId, uid);
|
||||||
|
intentBinding(intent, selected);
|
||||||
|
const publication = await verifyPublication(
|
||||||
|
selectedPaths,
|
||||||
|
intent,
|
||||||
|
selected,
|
||||||
|
receipt,
|
||||||
|
dependencies,
|
||||||
|
uid,
|
||||||
|
[0o400],
|
||||||
|
[0o500],
|
||||||
|
);
|
||||||
|
validateReceiptBinding(receipt, intent, publication);
|
||||||
|
return Object.freeze({
|
||||||
|
intent,
|
||||||
|
receipt,
|
||||||
|
authorizationPath: selectedPaths.authorization,
|
||||||
|
context: selected,
|
||||||
|
plan: planTerminal(selected),
|
||||||
|
reviewer: publication.authorization.receipt.reviewer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function prepareLocalReconciliationAutomationDecisionCommandFile(
|
export function prepareLocalReconciliationAutomationDecisionCommandFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
dependencies: LocalReconciliationAutomationDecisionDependencies = {},
|
dependencies: LocalReconciliationAutomationDecisionDependencies = {},
|
||||||
|
|||||||
@@ -11,17 +11,21 @@ const {
|
|||||||
commitLocalReconciliationCapture,
|
commitLocalReconciliationCapture,
|
||||||
commitLocalReconciliationApplication,
|
commitLocalReconciliationApplication,
|
||||||
commitLocalReconciliationAutomationDecision,
|
commitLocalReconciliationAutomationDecision,
|
||||||
|
applyLocalReconciliationAutomation,
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
commitLocalReconciliationReview,
|
commitLocalReconciliationReview,
|
||||||
prepareLocalReconciliationCapture,
|
prepareLocalReconciliationCapture,
|
||||||
prepareLocalReconciliationApplication,
|
prepareLocalReconciliationApplication,
|
||||||
prepareLocalReconciliationAutomationDecision,
|
prepareLocalReconciliationAutomationDecision,
|
||||||
|
readLocalReconciliationAutomationDecisionTerminal,
|
||||||
|
rollbackLocalReconciliationAutomationApply,
|
||||||
planLocalReconciliationAutomation,
|
planLocalReconciliationAutomation,
|
||||||
prepareLocalReconciliationPlan,
|
prepareLocalReconciliationPlan,
|
||||||
prepareLocalReconciliationReview,
|
prepareLocalReconciliationReview,
|
||||||
verifyLocalReconciliationCapture,
|
verifyLocalReconciliationCapture,
|
||||||
verifyLocalReconciliationApplication,
|
verifyLocalReconciliationApplication,
|
||||||
verifyLocalReconciliationAutomationDecision,
|
verifyLocalReconciliationAutomationDecision,
|
||||||
|
verifyLocalReconciliationAutomationApply,
|
||||||
verifyLocalReconciliationAutomationPlan,
|
verifyLocalReconciliationAutomationPlan,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
verifyLocalReconciliationReview,
|
verifyLocalReconciliationReview,
|
||||||
@@ -703,6 +707,41 @@ function automationDatabaseInitializer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function automationReadyDatabaseInitializer() {
|
||||||
|
const initializeLegacy = automationDatabaseInitializer();
|
||||||
|
return (paths) => {
|
||||||
|
initializeLegacy(paths);
|
||||||
|
fs.truncateSync(paths.targetDatabasePath, 0);
|
||||||
|
const migration = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`require('@qinglong/local-sqlite/migration')
|
||||||
|
.migrateLocalSqlitePath({ databasePath: process.argv[1], profile: 'edge' })
|
||||||
|
.catch((error) => { console.error(error); process.exitCode = 1; });`,
|
||||||
|
paths.targetDatabasePath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', cwd: path.join(__dirname, '..') },
|
||||||
|
);
|
||||||
|
assert.equal(migration.status, 0, migration.stderr);
|
||||||
|
fs.chmodSync(paths.targetDatabasePath, 0o600);
|
||||||
|
const target = new DatabaseSync(paths.targetDatabasePath);
|
||||||
|
target.exec(`
|
||||||
|
INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||||
|
"project_id", "subject_type", "subject_id", "version", "state",
|
||||||
|
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||||
|
"created_at_ms"
|
||||||
|
) VALUES (
|
||||||
|
'default', 'user', 'review-owner', 1, 'active', 'owner',
|
||||||
|
'automation-apply-owner-binding', 'user', 'review-owner', 1
|
||||||
|
);
|
||||||
|
PRAGMA wal_checkpoint(TRUNCATE);
|
||||||
|
PRAGMA journal_mode=DELETE;
|
||||||
|
`);
|
||||||
|
target.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
|
function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
|
||||||
const target = new DatabaseSync(targetDatabasePath);
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
if (occupied) {
|
if (occupied) {
|
||||||
@@ -719,6 +758,13 @@ function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
|
|||||||
return Object.freeze({});
|
return Object.freeze({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mutateReadyAutomationTarget({ targetDatabasePath }) {
|
||||||
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
|
target.exec('PRAGMA user_version=1');
|
||||||
|
target.close();
|
||||||
|
return Object.freeze({});
|
||||||
|
}
|
||||||
|
|
||||||
function mutatePlanningTarget({ targetDatabasePath }) {
|
function mutatePlanningTarget({ targetDatabasePath }) {
|
||||||
const target = new DatabaseSync(targetDatabasePath);
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
target.exec('INSERT INTO "QingLong3TaskDefinitions" (id) VALUES (1)');
|
target.exec('INSERT INTO "QingLong3TaskDefinitions" (id) VALUES (1)');
|
||||||
@@ -1080,9 +1126,14 @@ async function plannedAutomationFixture(t, options = {}) {
|
|||||||
applicationId: options.applicationId,
|
applicationId: options.applicationId,
|
||||||
reviewSuffix: `automation-decision-${suffix}`,
|
reviewSuffix: `automation-decision-${suffix}`,
|
||||||
createDefaultSidecars: false,
|
createDefaultSidecars: false,
|
||||||
initializeDatabases: automationDatabaseInitializer(),
|
initializeDatabases:
|
||||||
|
options.readyTarget === true
|
||||||
|
? automationReadyDatabaseInitializer()
|
||||||
|
: automationDatabaseInitializer(),
|
||||||
mutateTarget(paths) {
|
mutateTarget(paths) {
|
||||||
return mutateAutomationTarget(paths, options.occupied === true);
|
return options.readyTarget === true
|
||||||
|
? mutateReadyAutomationTarget(paths)
|
||||||
|
: mutateAutomationTarget(paths, options.occupied === true);
|
||||||
},
|
},
|
||||||
mutateDecisions(records) {
|
mutateDecisions(records) {
|
||||||
const selected = records.find(
|
const selected = records.find(
|
||||||
@@ -1094,8 +1145,10 @@ async function plannedAutomationFixture(t, options = {}) {
|
|||||||
record.disposition === 'exclude_legacy',
|
record.disposition === 'exclude_legacy',
|
||||||
);
|
);
|
||||||
assert.ok(selected);
|
assert.ok(selected);
|
||||||
selected.disposition = options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
selected.disposition =
|
||||||
selected.reason = options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
||||||
|
selected.reason =
|
||||||
|
options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const preparedApplication = await prepareLocalReconciliationApplication(
|
const preparedApplication = await prepareLocalReconciliationApplication(
|
||||||
@@ -1242,7 +1295,8 @@ function automationDecisionCommitFixture(
|
|||||||
options: {
|
options: {
|
||||||
...prepared.commandOptions,
|
...prepared.commandOptions,
|
||||||
targetDatabasePath: state.targetDatabasePath,
|
targetDatabasePath: state.targetDatabasePath,
|
||||||
ownerPepperKeyringDirectory: state.command.options.ownerPepperKeyringDirectory,
|
ownerPepperKeyringDirectory:
|
||||||
|
state.command.options.ownerPepperKeyringDirectory,
|
||||||
credentialFilePath: state.command.options.credentialFilePath,
|
credentialFilePath: state.command.options.credentialFilePath,
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
@@ -2885,7 +2939,10 @@ test('automation adapter builds a sealed row plan with bounded conflict evidence
|
|||||||
const application = await commitLocalReconciliationApplication(
|
const application = await commitLocalReconciliationApplication(
|
||||||
applicationCommitCommand(state, prepared),
|
applicationCommitCommand(state, prepared),
|
||||||
);
|
);
|
||||||
const automationRoot = path.join(path.dirname(state.captureRoot), 'automation-root');
|
const automationRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
'automation-root',
|
||||||
|
);
|
||||||
fs.mkdirSync(automationRoot, { mode: 0o700 });
|
fs.mkdirSync(automationRoot, { mode: 0o700 });
|
||||||
const targetBefore = fs.readFileSync(state.targetDatabasePath);
|
const targetBefore = fs.readFileSync(state.targetDatabasePath);
|
||||||
const command = {
|
const command = {
|
||||||
@@ -2944,7 +3001,10 @@ test('automation adapter builds a sealed row plan with bounded conflict evidence
|
|||||||
assert.equal(planText.includes('legacy-cron:1'), true);
|
assert.equal(planText.includes('legacy-cron:1'), true);
|
||||||
assert.equal(planText.includes('review-owner'), false);
|
assert.equal(planText.includes('review-owner'), false);
|
||||||
assert.equal(planText.includes('"requirement":"review_adopt"'), true);
|
assert.equal(planText.includes('"requirement":"review_adopt"'), true);
|
||||||
assert.equal(fs.readFileSync(state.targetDatabasePath).equals(targetBefore), true);
|
assert.equal(
|
||||||
|
fs.readFileSync(state.targetDatabasePath).equals(targetBefore),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
const replay = await planLocalReconciliationAutomation(command);
|
const replay = await planLocalReconciliationAutomation(command);
|
||||||
assert.equal(replay.status, 'existing');
|
assert.equal(replay.status, 'existing');
|
||||||
@@ -2994,6 +3054,7 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
|||||||
reviewId: '00000000-0000-4000-8000-000000000462',
|
reviewId: '00000000-0000-4000-8000-000000000462',
|
||||||
applicationId: '00000000-0000-4000-8000-000000000463',
|
applicationId: '00000000-0000-4000-8000-000000000463',
|
||||||
automationId: '00000000-0000-4000-8000-000000000464',
|
automationId: '00000000-0000-4000-8000-000000000464',
|
||||||
|
readyTarget: true,
|
||||||
});
|
});
|
||||||
assert.equal(state.planRows[0].requirement, 'review_adopt');
|
assert.equal(state.planRows[0].requirement, 'review_adopt');
|
||||||
const decisionId = '019b0000-0000-7000-8000-000000000461';
|
const decisionId = '019b0000-0000-7000-8000-000000000461';
|
||||||
@@ -3067,11 +3128,28 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
|||||||
verifyCommand,
|
verifyCommand,
|
||||||
);
|
);
|
||||||
assert.equal(verified.status, 'verified');
|
assert.equal(verified.status, 'verified');
|
||||||
assert.equal(verified.signedDecisionSetDigest, committed.signedDecisionSetDigest);
|
assert.equal(
|
||||||
|
verified.signedDecisionSetDigest,
|
||||||
|
committed.signedDecisionSetDigest,
|
||||||
|
);
|
||||||
const serialized = JSON.stringify(verified);
|
const serialized = JSON.stringify(verified);
|
||||||
assert.equal(serialized.includes('review-owner'), false);
|
assert.equal(serialized.includes('review-owner'), false);
|
||||||
assert.equal(serialized.includes(state.planRows[0].sourceDigest), false);
|
assert.equal(serialized.includes(state.planRows[0].sourceDigest), false);
|
||||||
assert.equal(serialized.includes(review.filePath), false);
|
assert.equal(serialized.includes(review.filePath), false);
|
||||||
|
const terminal = await readLocalReconciliationAutomationDecisionTerminal(
|
||||||
|
prepareCommand.options,
|
||||||
|
state.automationCommand.request.automationId,
|
||||||
|
process.getuid(),
|
||||||
|
);
|
||||||
|
assert.equal(terminal.receipt.decisionDigest, committed.decisionDigest);
|
||||||
|
assert.equal(
|
||||||
|
terminal.context.application.plan.applicationPlanDigest,
|
||||||
|
state.application.applicationPlanDigest,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
terminal.authorizationPath.endsWith('/authorization.ndjson'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
const commandPath = path.join(
|
const commandPath = path.join(
|
||||||
state.deploymentRoot,
|
state.deploymentRoot,
|
||||||
'automation-decision-verify.json',
|
'automation-decision-verify.json',
|
||||||
@@ -3093,6 +3171,249 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
|||||||
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||||
assert.equal(cli.stdout.includes('review-owner'), false);
|
assert.equal(cli.stdout.includes('review-owner'), false);
|
||||||
assert.equal(cli.stdout.includes(state.planRows[0].sourceDigest), false);
|
assert.equal(cli.stdout.includes(state.planRows[0].sourceDigest), false);
|
||||||
|
|
||||||
|
const automationApplyRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
'automation-apply-signed-success',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(automationApplyRoot, { mode: 0o700 });
|
||||||
|
const appliedAtMs = commit.command.request.committedAtMs + 1;
|
||||||
|
const targetIdentity = fs.statSync(state.targetDatabasePath);
|
||||||
|
const applyOptions = {
|
||||||
|
...prepareCommand.options,
|
||||||
|
automationApplyRoot,
|
||||||
|
targetDatabasePath: state.targetDatabasePath,
|
||||||
|
ownerPepperKeyringDirectory:
|
||||||
|
state.command.options.ownerPepperKeyringDirectory,
|
||||||
|
credentialFilePath: state.command.options.credentialFilePath,
|
||||||
|
};
|
||||||
|
const applyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
expectedDecisionDigest: committed.decisionDigest,
|
||||||
|
expectedHeadDigest: committed.instanceHeadDigest,
|
||||||
|
mutationId: '00000000-0000-4000-8000-000000000465',
|
||||||
|
requestId: 'automation-apply-signed-success',
|
||||||
|
appliedAtMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const applyDependencies = {
|
||||||
|
async openAuthenticationDatabase() {
|
||||||
|
return { async close() {} };
|
||||||
|
},
|
||||||
|
async authenticate(_database, options) {
|
||||||
|
assert.equal(
|
||||||
|
options.authenticationNamespace,
|
||||||
|
'local_reconciliation_automation_apply',
|
||||||
|
);
|
||||||
|
const authenticatedAtMs = options.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 liveApplyEvidence = readTargetDataReconciliationEvidenceForPaths(
|
||||||
|
{
|
||||||
|
profile: state.captureCommand.request.profile,
|
||||||
|
activationPath: state.captureCommand.request.activationPath,
|
||||||
|
legacySourcePath: state.captureCommand.request.legacySourcePath,
|
||||||
|
targetDatabasePath: state.targetDatabasePath,
|
||||||
|
expectedActivationDigest:
|
||||||
|
state.captureCommand.request.expectedActivationDigest,
|
||||||
|
},
|
||||||
|
process.getuid(),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
liveApplyEvidence.disposition,
|
||||||
|
'reconciliation_required',
|
||||||
|
JSON.stringify({
|
||||||
|
liveApplyEvidence,
|
||||||
|
activation: JSON.parse(
|
||||||
|
fs.readFileSync(state.captureCommand.request.activationPath, 'utf8'),
|
||||||
|
),
|
||||||
|
target: {
|
||||||
|
...fs.statSync(state.targetDatabasePath),
|
||||||
|
mode: fs.statSync(state.targetDatabasePath).mode & 0o777,
|
||||||
|
realpath: fs.realpathSync(state.targetDatabasePath),
|
||||||
|
sidecars: ['-wal', '-shm', '-journal'].map((suffix) =>
|
||||||
|
fs.existsSync(`${state.targetDatabasePath}${suffix}`),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const boundary of ['afterBackupPublished', 'afterPreparedHead']) {
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationAutomation(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`automation apply ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`automation apply ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationAutomation(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
async authenticate(database, options) {
|
||||||
|
const authenticated = await applyDependencies.authenticate(
|
||||||
|
database,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...authenticated,
|
||||||
|
principal: {
|
||||||
|
...authenticated.principal,
|
||||||
|
subject: { type: 'user', id: 'another-owner' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/current reviewer authentication is not strong or identical/,
|
||||||
|
);
|
||||||
|
for (const boundary of ['afterDatabaseCommit', 'afterReceiptPublished']) {
|
||||||
|
await assert.rejects(
|
||||||
|
applyLocalReconciliationAutomation(applyCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`automation apply ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`automation apply ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const applied = await applyLocalReconciliationAutomation(
|
||||||
|
applyCommand,
|
||||||
|
applyDependencies,
|
||||||
|
);
|
||||||
|
assert.equal(applied.status, 'existing');
|
||||||
|
assert.equal(applied.state, 'reconciliation_automation_applied');
|
||||||
|
assert.equal(applied.adoptedTaskCount, 1);
|
||||||
|
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
|
||||||
|
const applyReplay = await applyLocalReconciliationAutomation(
|
||||||
|
applyCommand,
|
||||||
|
applyDependencies,
|
||||||
|
);
|
||||||
|
assert.equal(applyReplay.status, 'existing');
|
||||||
|
const applyVerified = await verifyLocalReconciliationAutomationApply({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply.verify',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(applyVerified.status, 'verified');
|
||||||
|
const rollbackCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply.rollback',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
expectedHeadDigest: applied.instanceHeadDigest,
|
||||||
|
rolledBackAtMs: appliedAtMs + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const boundary of ['afterRestore', 'afterRollbackReceipt']) {
|
||||||
|
await assert.rejects(
|
||||||
|
rollbackLocalReconciliationAutomationApply(rollbackCommand, {
|
||||||
|
...applyDependencies,
|
||||||
|
[boundary]() {
|
||||||
|
throw new Error(`automation rollback ${boundary} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`automation rollback ${boundary} response loss`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rolledBack = await rollbackLocalReconciliationAutomationApply(
|
||||||
|
rollbackCommand,
|
||||||
|
applyDependencies,
|
||||||
|
);
|
||||||
|
assert.equal(rolledBack.status, 'existing');
|
||||||
|
assert.equal(rolledBack.state, 'reconciliation_automation_rolled_back');
|
||||||
|
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await rollbackLocalReconciliationAutomationApply(
|
||||||
|
rollbackCommand,
|
||||||
|
applyDependencies,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
'existing',
|
||||||
|
);
|
||||||
|
const rollbackVerified = await verifyLocalReconciliationAutomationApply({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply.verify',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(rollbackVerified.status, 'verified');
|
||||||
|
assert.equal(rollbackVerified.state, 'reconciliation_automation_rolled_back');
|
||||||
|
const applyVerifyPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'automation-apply-verify.json',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
applyVerifyPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply.verify',
|
||||||
|
options: applyOptions,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
expectedApplyDigest: applied.applyDigest,
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
const applyCli = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||||
|
'reconciliation-automation-apply-verify',
|
||||||
|
'--command-file',
|
||||||
|
applyVerifyPath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(applyCli.status, 0, applyCli.stderr);
|
||||||
|
assert.equal(JSON.parse(applyCli.stdout).status, 'verified');
|
||||||
|
const restored = new DatabaseSync(state.targetDatabasePath, {
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
restored
|
||||||
|
.prepare('SELECT COUNT(*) AS count FROM "QingLong3LegacyAdoptions"')
|
||||||
|
.get().count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
restored.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('automation decision rejects conflict adoption, another reviewer and weak assurance', async (t) => {
|
test('automation decision rejects conflict adoption, another reviewer and weak assurance', async (t) => {
|
||||||
@@ -3294,8 +3615,7 @@ test('automation decision verification rejects sealed authorization and plan dri
|
|||||||
suffix: 'authorization-drift',
|
suffix: 'authorization-drift',
|
||||||
automationId: '00000000-0000-4000-8000-000000000475',
|
automationId: '00000000-0000-4000-8000-000000000475',
|
||||||
});
|
});
|
||||||
const authorizationDecisionId =
|
const authorizationDecisionId = '019b0000-0000-7000-8000-000000000475';
|
||||||
'019b0000-0000-7000-8000-000000000475';
|
|
||||||
const authorizationReview = automationDecisionReviewFile(
|
const authorizationReview = automationDecisionReviewFile(
|
||||||
authorizationState,
|
authorizationState,
|
||||||
authorizationDecisionId,
|
authorizationDecisionId,
|
||||||
@@ -3483,7 +3803,9 @@ test('automation row plan fails closed to manual review on a target task collisi
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('automation row planner makes an empty table no-effect and a missing timezone manual', (t) => {
|
test('automation row planner makes an empty table no-effect and a missing timezone manual', (t) => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-automation-row-unit-'));
|
const root = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ql3-automation-row-unit-'),
|
||||||
|
);
|
||||||
t.after(() => removeFixtureRoot(root));
|
t.after(() => removeFixtureRoot(root));
|
||||||
const legacySourcePath = path.join(root, 'legacy.sqlite');
|
const legacySourcePath = path.join(root, 'legacy.sqlite');
|
||||||
const recoveryPath = path.join(root, 'recovery.sqlite');
|
const recoveryPath = path.join(root, 'recovery.sqlite');
|
||||||
@@ -3548,7 +3870,10 @@ test('automation row planner makes an empty table no-effect and a missing timezo
|
|||||||
manual = writeLocalReconciliationAutomationPlan({
|
manual = writeLocalReconciliationAutomationPlan({
|
||||||
descriptor: manualDescriptor,
|
descriptor: manualDescriptor,
|
||||||
maxBytes: 64 * 1024,
|
maxBytes: 64 * 1024,
|
||||||
header: { ...header, automationId: '00000000-0000-4000-8000-00000000043b' },
|
header: {
|
||||||
|
...header,
|
||||||
|
automationId: '00000000-0000-4000-8000-00000000043b',
|
||||||
|
},
|
||||||
legacy,
|
legacy,
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
@@ -3639,7 +3964,13 @@ test('automation row planning replays every publication boundary and rejects dri
|
|||||||
['seal', '000000000449', '00000000044a', '00000000044b', '00000000044c'],
|
['seal', '000000000449', '00000000044a', '00000000044b', '00000000044c'],
|
||||||
['head', '00000000044d', '00000000044e', '00000000044f', '000000000450'],
|
['head', '00000000044d', '00000000044e', '00000000044f', '000000000450'],
|
||||||
];
|
];
|
||||||
for (const [window, planTail, reviewTail, applicationTail, automationTail] of windows) {
|
for (const [
|
||||||
|
window,
|
||||||
|
planTail,
|
||||||
|
reviewTail,
|
||||||
|
applicationTail,
|
||||||
|
automationTail,
|
||||||
|
] of windows) {
|
||||||
const state = await reviewedApplicationFixture(t, {
|
const state = await reviewedApplicationFixture(t, {
|
||||||
planId: `00000000-0000-4000-8000-${planTail}`,
|
planId: `00000000-0000-4000-8000-${planTail}`,
|
||||||
reviewId: `00000000-0000-4000-8000-${reviewTail}`,
|
reviewId: `00000000-0000-4000-8000-${reviewTail}`,
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 165,
|
sourceFiles: 168,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 164,
|
nestedSourceFiles: 167,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user