mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +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 与显式
|
||||
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` 也不能证明
|
||||
Automation 行级 command/trigger 兼容,更不能覆盖 Secret custody、append-only history、Plugin/AI 外部资产与 Identity/Policy 语义。
|
||||
因此既有 Local Owner 新增 `reconciliation.application.prepare|commit|verify`,以
|
||||
|
||||
@@ -34,6 +34,9 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_automation_planned'
|
||||
| 'reconciliation_automation_decision_prepared'
|
||||
| 'reconciliation_automation_reviewed'
|
||||
| 'reconciliation_automation_apply_prepared'
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -176,6 +179,9 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_automation_planned' &&
|
||||
head.state !== 'reconciliation_automation_decision_prepared' &&
|
||||
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 !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
@@ -353,6 +359,9 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_automation_planned'
|
||||
| 'reconciliation_automation_decision_prepared'
|
||||
| 'reconciliation_automation_reviewed'
|
||||
| 'reconciliation_automation_apply_prepared'
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -402,6 +411,9 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_automation_planned' ||
|
||||
current.state === 'reconciliation_automation_decision_prepared' ||
|
||||
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_running' ||
|
||||
current.state === 'legacy_ready')
|
||||
@@ -439,6 +451,12 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_automation_planned') ||
|
||||
(state === 'reconciliation_automation_reviewed' &&
|
||||
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 === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
|
||||
@@ -142,9 +142,18 @@ import {
|
||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||
prepareLocalReconciliationAutomationDecision,
|
||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||
readLocalReconciliationAutomationDecisionTerminal,
|
||||
verifyLocalReconciliationAutomationDecision,
|
||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||
} from './reconciliation/application/automation/decisionCoordinator';
|
||||
import {
|
||||
applyLocalReconciliationAutomation,
|
||||
applyLocalReconciliationAutomationCommandFile,
|
||||
rollbackLocalReconciliationAutomationApply,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
verifyLocalReconciliationAutomationApply,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
} from './reconciliation/application/automation/applyCoordinator';
|
||||
|
||||
export {
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -173,12 +182,31 @@ export {
|
||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||
prepareLocalReconciliationAutomationDecision,
|
||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||
readLocalReconciliationAutomationDecisionTerminal,
|
||||
commitLocalReconciliationAutomationDecision,
|
||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||
verifyLocalReconciliationAutomationDecision,
|
||||
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 {
|
||||
normalizeLocalReconciliationApplicationCommitCommand,
|
||||
normalizeLocalReconciliationApplicationPrepareCommand,
|
||||
@@ -214,9 +242,7 @@ export {
|
||||
type LocalReconciliationAutomationPlanResult,
|
||||
type LocalReconciliationAutomationVerifyCommand,
|
||||
} from './reconciliation/application/automation/contract';
|
||||
export {
|
||||
type LocalReconciliationAutomationPlanDependencies,
|
||||
} from './reconciliation/application/automation/coordinator';
|
||||
export { type LocalReconciliationAutomationPlanDependencies } from './reconciliation/application/automation/coordinator';
|
||||
export {
|
||||
normalizeLocalReconciliationAutomationDecisionCommitCommand,
|
||||
normalizeLocalReconciliationAutomationDecisionPrepareCommand,
|
||||
@@ -229,9 +255,7 @@ export {
|
||||
type LocalReconciliationAutomationDecisionTerminalResult,
|
||||
type LocalReconciliationAutomationDecisionVerifyCommand,
|
||||
} from './reconciliation/application/automation/decisionContract';
|
||||
export {
|
||||
type LocalReconciliationAutomationDecisionDependencies,
|
||||
} from './reconciliation/application/automation/decisionCoordinator';
|
||||
export { type LocalReconciliationAutomationDecisionDependencies } from './reconciliation/application/automation/decisionCoordinator';
|
||||
export {
|
||||
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||
applyLocalReconciliationAutomationCommandFile,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
@@ -45,7 +48,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|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> {
|
||||
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-commit' &&
|
||||
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-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
@@ -198,6 +204,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? commitLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-decision-verify'
|
||||
? 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'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: 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,
|
||||
} from '@qinglong/local-owner-console/authenticated-command';
|
||||
import { openLocalSqliteAuthenticationReadDatabase } from '@qinglong/local-sqlite/authentication-read';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
@@ -103,6 +104,15 @@ export interface LocalReconciliationAutomationDecisionDependencies
|
||||
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 {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation automation decision ${message}`,
|
||||
@@ -149,17 +159,17 @@ function validateDirectory(
|
||||
return mode;
|
||||
}
|
||||
|
||||
function validateCatalog(selected: Readonly<DecisionPaths>, terminal: boolean): void {
|
||||
function validateCatalog(
|
||||
selected: Readonly<DecisionPaths>,
|
||||
terminal: boolean,
|
||||
): void {
|
||||
const allowed = new Set([
|
||||
'authorization.ndjson',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'staging',
|
||||
...(!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 })) {
|
||||
@@ -242,7 +252,9 @@ function terminalJson(
|
||||
}
|
||||
|
||||
async function context(
|
||||
options: Readonly<LocalReconciliationAutomationDecisionPrepareCommand['options']>,
|
||||
options: Readonly<
|
||||
LocalReconciliationAutomationDecisionPrepareCommand['options']
|
||||
>,
|
||||
automationId: string,
|
||||
uid: number,
|
||||
): Promise<Readonly<DecisionContext>> {
|
||||
@@ -412,7 +424,7 @@ export async function prepareLocalReconciliationAutomationDecision(
|
||||
);
|
||||
if (
|
||||
current.automation.receipt.automationPlanDigest !==
|
||||
command.request.expectedAutomationPlanDigest
|
||||
command.request.expectedAutomationPlanDigest
|
||||
) {
|
||||
configurationError('expected automation plan digest drifted');
|
||||
}
|
||||
@@ -438,7 +450,8 @@ export async function prepareLocalReconciliationAutomationDecision(
|
||||
if (
|
||||
(head.state === 'reconciliation_automation_planned' &&
|
||||
(head.headDigest !== command.request.expectedHeadDigest ||
|
||||
head.sourceRecordDigest !== intent.command.request.expectedAutomationPlanDigest)) ||
|
||||
head.sourceRecordDigest !==
|
||||
intent.command.request.expectedAutomationPlanDigest)) ||
|
||||
(head.state === 'reconciliation_automation_decision_prepared' &&
|
||||
head.sourceRecordDigest !== intent.preparationDigest) ||
|
||||
(head.state !== 'reconciliation_automation_planned' &&
|
||||
@@ -453,7 +466,8 @@ export async function prepareLocalReconciliationAutomationDecision(
|
||||
ensurePrivateDirectory(selected.root, uid, 'automationDecisionDirectory');
|
||||
ensurePrivateDirectory(selected.staging, uid, 'automationDecisionStaging');
|
||||
validateCatalog(selected, false);
|
||||
const contents = localReconciliationAutomationDecisionEvidenceContents(intent);
|
||||
const contents =
|
||||
localReconciliationAutomationDecisionEvidenceContents(intent);
|
||||
preflightPublishedFile(
|
||||
selected.intent,
|
||||
contents,
|
||||
@@ -505,7 +519,9 @@ function validateCommitBinding(
|
||||
|
||||
function strongReviewer(
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
original: Readonly<DecisionContext['application']['review']['authorization']['header']['reviewer']>,
|
||||
original: Readonly<
|
||||
DecisionContext['application']['review']['authorization']['header']['reviewer']
|
||||
>,
|
||||
committedAtMs: number,
|
||||
) {
|
||||
const principal = authenticated.principal;
|
||||
@@ -598,15 +614,16 @@ async function authorization(
|
||||
);
|
||||
if (
|
||||
capture.command.request.targetDatabasePath !==
|
||||
command.options.targetDatabasePath
|
||||
command.options.targetDatabasePath
|
||||
) {
|
||||
configurationError('authentication database is detached from capture');
|
||||
}
|
||||
const openRequirements = createLocalReconciliationAutomationRequirementFactory(
|
||||
selected.automation.planPath,
|
||||
selected.automation.receipt,
|
||||
uid,
|
||||
);
|
||||
const openRequirements =
|
||||
createLocalReconciliationAutomationRequirementFactory(
|
||||
selected.automation.planPath,
|
||||
selected.automation.receipt,
|
||||
uid,
|
||||
);
|
||||
const keyringPath =
|
||||
selected.application.intent.command.options.issuerKeyringPath;
|
||||
ensureLocalReconciliationReviewIssuerKeyring(keyringPath);
|
||||
@@ -774,11 +791,12 @@ async function verifyPublication(
|
||||
allowedParentModes: readonly (0o500 | 0o700)[],
|
||||
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
|
||||
const terminal = planTerminal(selected);
|
||||
const openRequirements = createLocalReconciliationAutomationRequirementFactory(
|
||||
selected.automation.planPath,
|
||||
selected.automation.receipt,
|
||||
uid,
|
||||
);
|
||||
const openRequirements =
|
||||
createLocalReconciliationAutomationRequirementFactory(
|
||||
selected.automation.planPath,
|
||||
selected.automation.receipt,
|
||||
uid,
|
||||
);
|
||||
const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider(
|
||||
selected.application.intent.command.options.issuerKeyringPath,
|
||||
);
|
||||
@@ -862,8 +880,7 @@ export async function commitLocalReconciliationAutomationDecision(
|
||||
receipt.preparedHeadDigest !== command.request.expectedHeadDigest ||
|
||||
receipt.issuedAtMs !== command.request.committedAtMs ||
|
||||
receipt.expiresAtMs !==
|
||||
command.request.committedAtMs +
|
||||
command.request.authorizationLifetimeMs
|
||||
command.request.committedAtMs + command.request.authorizationLifetimeMs
|
||||
) {
|
||||
configurationError('terminal receipt is not an exact command replay');
|
||||
}
|
||||
@@ -1013,6 +1030,58 @@ export async function verifyLocalReconciliationAutomationDecision(
|
||||
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(
|
||||
filePath: string,
|
||||
dependencies: LocalReconciliationAutomationDecisionDependencies = {},
|
||||
|
||||
@@ -11,17 +11,21 @@ const {
|
||||
commitLocalReconciliationCapture,
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationAutomationDecision,
|
||||
applyLocalReconciliationAutomation,
|
||||
commitLocalReconciliationPlan,
|
||||
commitLocalReconciliationReview,
|
||||
prepareLocalReconciliationCapture,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationAutomationDecision,
|
||||
readLocalReconciliationAutomationDecisionTerminal,
|
||||
rollbackLocalReconciliationAutomationApply,
|
||||
planLocalReconciliationAutomation,
|
||||
prepareLocalReconciliationPlan,
|
||||
prepareLocalReconciliationReview,
|
||||
verifyLocalReconciliationCapture,
|
||||
verifyLocalReconciliationApplication,
|
||||
verifyLocalReconciliationAutomationDecision,
|
||||
verifyLocalReconciliationAutomationApply,
|
||||
verifyLocalReconciliationAutomationPlan,
|
||||
verifyLocalReconciliationPlan,
|
||||
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) {
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
if (occupied) {
|
||||
@@ -719,6 +758,13 @@ function mutateAutomationTarget({ targetDatabasePath }, occupied = false) {
|
||||
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 }) {
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
target.exec('INSERT INTO "QingLong3TaskDefinitions" (id) VALUES (1)');
|
||||
@@ -1080,9 +1126,14 @@ async function plannedAutomationFixture(t, options = {}) {
|
||||
applicationId: options.applicationId,
|
||||
reviewSuffix: `automation-decision-${suffix}`,
|
||||
createDefaultSidecars: false,
|
||||
initializeDatabases: automationDatabaseInitializer(),
|
||||
initializeDatabases:
|
||||
options.readyTarget === true
|
||||
? automationReadyDatabaseInitializer()
|
||||
: automationDatabaseInitializer(),
|
||||
mutateTarget(paths) {
|
||||
return mutateAutomationTarget(paths, options.occupied === true);
|
||||
return options.readyTarget === true
|
||||
? mutateReadyAutomationTarget(paths)
|
||||
: mutateAutomationTarget(paths, options.occupied === true);
|
||||
},
|
||||
mutateDecisions(records) {
|
||||
const selected = records.find(
|
||||
@@ -1094,8 +1145,10 @@ async function plannedAutomationFixture(t, options = {}) {
|
||||
record.disposition === 'exclude_legacy',
|
||||
);
|
||||
assert.ok(selected);
|
||||
selected.disposition = options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
||||
selected.reason = options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
||||
selected.disposition =
|
||||
options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
||||
selected.reason =
|
||||
options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
||||
},
|
||||
});
|
||||
const preparedApplication = await prepareLocalReconciliationApplication(
|
||||
@@ -1242,7 +1295,8 @@ function automationDecisionCommitFixture(
|
||||
options: {
|
||||
...prepared.commandOptions,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
ownerPepperKeyringDirectory: state.command.options.ownerPepperKeyringDirectory,
|
||||
ownerPepperKeyringDirectory:
|
||||
state.command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: state.command.options.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
@@ -2885,7 +2939,10 @@ test('automation adapter builds a sealed row plan with bounded conflict evidence
|
||||
const application = await commitLocalReconciliationApplication(
|
||||
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 });
|
||||
const targetBefore = fs.readFileSync(state.targetDatabasePath);
|
||||
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('review-owner'), false);
|
||||
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);
|
||||
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',
|
||||
applicationId: '00000000-0000-4000-8000-000000000463',
|
||||
automationId: '00000000-0000-4000-8000-000000000464',
|
||||
readyTarget: true,
|
||||
});
|
||||
assert.equal(state.planRows[0].requirement, 'review_adopt');
|
||||
const decisionId = '019b0000-0000-7000-8000-000000000461';
|
||||
@@ -3067,11 +3128,28 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
||||
verifyCommand,
|
||||
);
|
||||
assert.equal(verified.status, 'verified');
|
||||
assert.equal(verified.signedDecisionSetDigest, committed.signedDecisionSetDigest);
|
||||
assert.equal(
|
||||
verified.signedDecisionSetDigest,
|
||||
committed.signedDecisionSetDigest,
|
||||
);
|
||||
const serialized = JSON.stringify(verified);
|
||||
assert.equal(serialized.includes('review-owner'), false);
|
||||
assert.equal(serialized.includes(state.planRows[0].sourceDigest), 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(
|
||||
state.deploymentRoot,
|
||||
'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(cli.stdout.includes('review-owner'), 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) => {
|
||||
@@ -3294,8 +3615,7 @@ test('automation decision verification rejects sealed authorization and plan dri
|
||||
suffix: 'authorization-drift',
|
||||
automationId: '00000000-0000-4000-8000-000000000475',
|
||||
});
|
||||
const authorizationDecisionId =
|
||||
'019b0000-0000-7000-8000-000000000475';
|
||||
const authorizationDecisionId = '019b0000-0000-7000-8000-000000000475';
|
||||
const authorizationReview = automationDecisionReviewFile(
|
||||
authorizationState,
|
||||
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) => {
|
||||
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));
|
||||
const legacySourcePath = path.join(root, 'legacy.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({
|
||||
descriptor: manualDescriptor,
|
||||
maxBytes: 64 * 1024,
|
||||
header: { ...header, automationId: '00000000-0000-4000-8000-00000000043b' },
|
||||
header: {
|
||||
...header,
|
||||
automationId: '00000000-0000-4000-8000-00000000043b',
|
||||
},
|
||||
legacy,
|
||||
target,
|
||||
});
|
||||
@@ -3639,7 +3964,13 @@ test('automation row planning replays every publication boundary and rejects dri
|
||||
['seal', '000000000449', '00000000044a', '00000000044b', '00000000044c'],
|
||||
['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, {
|
||||
planId: `00000000-0000-4000-8000-${planTail}`,
|
||||
reviewId: `00000000-0000-4000-8000-${reviewTail}`,
|
||||
|
||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 165,
|
||||
sourceFiles: 168,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 50,
|
||||
nestedSourceFiles: 164,
|
||||
nestedSourceFiles: 167,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user