diff --git a/packages/ql3-local-admin/package.json b/packages/ql3-local-admin/package.json index a0b1ecca..12807feb 100644 --- a/packages/ql3-local-admin/package.json +++ b/packages/ql3-local-admin/package.json @@ -45,6 +45,11 @@ "require": "./dist/legacy-adoption/legacyCrontabInspection.js", "default": "./dist/legacy-adoption/legacyCrontabInspection.js" }, + "./reconciliation-automation-decision": { + "types": "./dist/legacy-adoption/reconciliationAutomationDecision.d.ts", + "require": "./dist/legacy-adoption/reconciliationAutomationDecision.js", + "default": "./dist/legacy-adoption/reconciliationAutomationDecision.js" + }, "./package-staging": { "types": "./dist/plugin-package/pluginPackageStaging.d.ts", "require": "./dist/plugin-package/pluginPackageStaging.js", diff --git a/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabDecisionAuthorizationFile.ts b/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabDecisionAuthorizationFile.ts index 0606f1c3..676c8c8b 100644 --- a/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabDecisionAuthorizationFile.ts +++ b/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabDecisionAuthorizationFile.ts @@ -88,6 +88,8 @@ export interface VerifyLegacyCrontabDecisionAuthorizationFileOptions { readonly expectedPlanDigest: string; readonly expectedInventoryDigest: string; readonly keyProvider: LocalSecretKeyProvider; + readonly allowedModes?: readonly (0o400 | 0o600)[]; + readonly allowedParentModes?: readonly (0o500 | 0o700)[]; readonly verifyReceipt: ( receipt: unknown, decisions: Iterable, @@ -218,7 +220,47 @@ function currentUid(): number { return uid; } -function assertPrivateParent(filePath: string, uid: number): void { +function verificationModes( + value: readonly (0o400 | 0o600)[] | undefined, +): readonly number[] { + if (value === undefined) return Object.freeze([0o600]); + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > 2 || + value.some((mode) => mode !== 0o400 && mode !== 0o600) || + new Set(value).size !== value.length + ) { + throw new LegacyCrontabDecisionAuthorizationFileError( + 'allowed file modes are invalid', + ); + } + return Object.freeze([...value]); +} + +function verificationParentModes( + value: readonly (0o500 | 0o700)[] | undefined, +): readonly number[] { + if (value === undefined) return Object.freeze([0o700]); + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > 2 || + value.some((mode) => mode !== 0o500 && mode !== 0o700) || + new Set(value).size !== value.length + ) { + throw new LegacyCrontabDecisionAuthorizationFileError( + 'allowed parent modes are invalid', + ); + } + return Object.freeze([...value]); +} + +function assertPrivateParent( + filePath: string, + uid: number, + allowedModes: readonly number[], +): void { let stat: fs.Stats; try { stat = fs.lstatSync(path.dirname(filePath)); @@ -232,7 +274,7 @@ function assertPrivateParent(filePath: string, uid: number): void { !stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || - (stat.mode & 0o777) !== 0o700 + !allowedModes.includes(stat.mode & 0o777) ) { throw new LegacyCrontabDecisionAuthorizationFileError( 'parent must be an owner-only real directory', @@ -693,7 +735,7 @@ export async function publishLegacyCrontabDecisionAuthorizationFile( ): Promise { const filePath = authorizationPath(options.filePath); const uid = currentUid(); - assertPrivateParent(filePath, uid); + assertPrivateParent(filePath, uid, [0o700]); const fileHeader = header(options); const temporary = path.join( path.dirname(filePath), @@ -860,7 +902,9 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal( ): Promise { const filePath = authorizationPath(options.filePath); const uid = currentUid(); - assertPrivateParent(filePath, uid); + const allowedModes = verificationModes(options.allowedModes); + const allowedParentModes = verificationParentModes(options.allowedParentModes); + assertPrivateParent(filePath, uid, allowedParentModes); assertHeaderIdentity({ decisionId: options.expectedDecisionId, profile: options.expectedProfile, @@ -874,7 +918,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal( !before.isFile() || before.isSymbolicLink() || Number(before.uid) !== uid || - (Number(before.mode) & 0o777) !== 0o600 || + !allowedModes.includes(Number(before.mode) & 0o777) || before.size < 1n || before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_AUTHORIZATION_FILE_BYTES) ) { @@ -1032,7 +1076,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal( afterPath.mtimeNs !== opened.mtimeNs || afterPath.ctimeNs !== opened.ctimeNs || Number(afterPath.uid) !== uid || - (Number(afterPath.mode) & 0o777) !== 0o600 + !allowedModes.includes(Number(afterPath.mode) & 0o777) ) { throw new LegacyCrontabDecisionAuthorizationFileError( 'file identity changed during verification', diff --git a/packages/ql3-local-admin/src/legacy-adoption/reconciliationAutomationDecision.ts b/packages/ql3-local-admin/src/legacy-adoption/reconciliationAutomationDecision.ts new file mode 100644 index 00000000..772d4f9c --- /dev/null +++ b/packages/ql3-local-admin/src/legacy-adoption/reconciliationAutomationDecision.ts @@ -0,0 +1,411 @@ +// Reconciliation Automation owns a plan-bound facade over legacy row decisions. +import type { DatabaseSync } from 'node:sqlite'; +import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret'; +import type { SecurityPrincipal } from '@qinglong/runtime-core/security'; +import { + publishLegacyCrontabDecisionAuthorizationFile, + withVerifiedLegacyCrontabDecisionAuthorizationFile, + type LegacyCrontabDecisionAuthorizationFileResult, + type VerifiedLegacyCrontabDecisionAuthorizationFileScope, +} from './legacyCrontabDecisionAuthorizationFile'; +import { + createLegacyCrontabAdoptionDecisionReceipt, + verifyLegacyCrontabAdoptionDecisionReceipt, + type LegacyCrontabAdoptionDecision, +} from './legacyCrontabDecisionReceipt'; +import type { LegacyCrontabAdoptionClassification } from './legacyCrontabAdoption'; +import { withPrivateLegacyCrontabAdoptionDecisionReviewFile } from './legacyCrontabDecisionReviewFile'; + +export type ReconciliationAutomationDecisionRequirementKind = + | 'review_adopt' + | 'review_skip_conflict' + | 'manual_required'; + +export interface ReconciliationAutomationDecisionRequirement { + readonly rowOrdinal: number; + readonly sourceDigest: string; + readonly classification: LegacyCrontabAdoptionClassification; + readonly requirement: ReconciliationAutomationDecisionRequirementKind; +} + +export interface ReconciliationAutomationDecisionIdentity { + readonly decisionId: string; + readonly profile: 'edge' | 'standalone'; + readonly automationPlanDigest: string; + readonly inventoryDigest: string; +} + +interface ReconciliationAutomationDecisionVerificationOptions + extends ReconciliationAutomationDecisionIdentity { + readonly authorizationPath: string; + readonly sourceClient: DatabaseSync; + readonly timezone: string | null; + readonly keyProvider: LocalSecretKeyProvider; + readonly observedAtMs: number; + readonly openRequirements: () => Iterable; + readonly allowedModes?: readonly (0o400 | 0o600)[]; + readonly allowedParentModes?: readonly (0o500 | 0o700)[]; +} + +export interface IssueReconciliationAutomationDecisionOptions + extends ReconciliationAutomationDecisionVerificationOptions { + readonly reviewFilePath: string; + readonly reviewer: SecurityPrincipal; + readonly issuedAtMs: number; + readonly expiresAtMs: number; + readonly confirmExternalAuthority: () => void | Promise; +} + +export interface RecoverReconciliationAutomationDecisionOptions + extends ReconciliationAutomationDecisionVerificationOptions { + readonly reviewFilePath: string; +} + +export interface ReconciliationAutomationDecisionPublication { + readonly authorization: LegacyCrontabDecisionAuthorizationFileResult; + readonly reviewFileDigest: string; +} + +export interface VerifiedReconciliationAutomationDecisionScope + extends VerifiedLegacyCrontabDecisionAuthorizationFileScope { + readonly decisions: Iterable; +} + +export class ReconciliationAutomationDecisionError extends Error { + readonly code = 'RECONCILIATION_AUTOMATION_DECISION_INVALID'; + + constructor(message: string, readonly cause?: unknown) { + super(`Reconciliation Automation decision is invalid: ${message}`); + this.name = 'ReconciliationAutomationDecisionError'; + } +} + +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const CLASSIFICATIONS = Object.freeze([ + 'lossless', + 'requires_shell_compatibility', + 'requires_manual_action', + 'malformed', +] as const); +const REQUIREMENTS = Object.freeze([ + 'review_adopt', + 'review_skip_conflict', + 'manual_required', +] as const); + +function requirement( + value: ReconciliationAutomationDecisionRequirement, +): Readonly { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).sort().join('\0') !== + ['classification', 'requirement', 'rowOrdinal', 'sourceDigest'] + .sort() + .join('\0') || + !Number.isSafeInteger(value.rowOrdinal) || + value.rowOrdinal < 1 || + !DIGEST_PATTERN.test(value.sourceDigest) || + !CLASSIFICATIONS.includes(value.classification) || + !REQUIREMENTS.includes(value.requirement) + ) { + throw new ReconciliationAutomationDecisionError( + 'plan requirement is invalid', + ); + } + return Object.freeze({ ...value }); +} + +function iterator(value: Iterable, label: string): Iterator { + if ( + !value || + (typeof value !== 'object' && typeof value !== 'function') || + typeof value[Symbol.iterator] !== 'function' + ) { + throw new ReconciliationAutomationDecisionError(`${label} is invalid`); + } + const selected = value[Symbol.iterator](); + if (!selected || typeof selected.next !== 'function') { + throw new ReconciliationAutomationDecisionError(`${label} is invalid`); + } + return selected; +} + +function decisionsBoundToPlan( + decisions: Iterable, + openRequirements: () => Iterable, +): Iterable { + if (typeof openRequirements !== 'function') { + throw new ReconciliationAutomationDecisionError( + 'plan requirement factory is invalid', + ); + } + return (function* (): Iterable { + const decisionIterator = iterator(decisions, 'decision stream'); + const requirementIterator = iterator( + openRequirements(), + 'plan requirement stream', + ); + let complete = false; + try { + for (;;) { + const nextDecision = decisionIterator.next(); + const nextRequirement = requirementIterator.next(); + if (nextDecision.done || nextRequirement.done) { + if (nextDecision.done !== nextRequirement.done) { + throw new ReconciliationAutomationDecisionError( + 'decision and plan row counts differ', + ); + } + complete = true; + return; + } + const expected = requirement(nextRequirement.value); + const decision = nextDecision.value; + if ( + decision.rowOrdinal !== expected.rowOrdinal || + decision.sourceDigest !== expected.sourceDigest + ) { + throw new ReconciliationAutomationDecisionError( + 'decision is detached from its plan row', + ); + } + if ( + expected.requirement !== 'review_adopt' && + decision.disposition !== 'skip' + ) { + throw new ReconciliationAutomationDecisionError( + 'conflict or manual row cannot be adopted', + ); + } + yield decision; + } + } finally { + if (!complete) { + try { + decisionIterator.return?.(); + } catch { + // Preserve the binding failure. + } + try { + requirementIterator.return?.(); + } catch { + // Preserve the binding failure. + } + } + } + })(); +} + +function sameDecision( + left: LegacyCrontabAdoptionDecision, + right: LegacyCrontabAdoptionDecision, +): boolean { + return ( + left.rowOrdinal === right.rowOrdinal && + left.sourceDigest === right.sourceDigest && + left.disposition === right.disposition && + left.reason === right.reason + ); +} + +function verifiedOptions( + options: ReconciliationAutomationDecisionVerificationOptions, + openRequirements: () => Iterable, +) { + return { + filePath: options.authorizationPath, + expectedDecisionId: options.decisionId, + expectedProfile: options.profile, + expectedPlanDigest: options.automationPlanDigest, + expectedInventoryDigest: options.inventoryDigest, + keyProvider: options.keyProvider, + ...(options.allowedModes === undefined + ? {} + : { allowedModes: options.allowedModes }), + ...(options.allowedParentModes === undefined + ? {} + : { allowedParentModes: options.allowedParentModes }), + verifyReceipt: ( + receipt: unknown, + decisions: Iterable, + ) => + verifyLegacyCrontabAdoptionDecisionReceipt( + options.sourceClient, + options.timezone, + receipt, + decisionsBoundToPlan(decisions, openRequirements), + options.observedAtMs, + ), + } as const; +} + +export async function issueReconciliationAutomationDecision( + options: IssueReconciliationAutomationDecisionOptions, +): Promise> { + try { + return await withPrivateLegacyCrontabAdoptionDecisionReviewFile( + { + filePath: options.reviewFilePath, + expectedDecisionId: options.decisionId, + expectedProfile: options.profile, + expectedPlanDigest: options.automationPlanDigest, + expectedInventoryDigest: options.inventoryDigest, + }, + async (review) => { + const authorization = + await publishLegacyCrontabDecisionAuthorizationFile({ + filePath: options.authorizationPath, + decisionId: options.decisionId, + profile: options.profile, + planDigest: options.automationPlanDigest, + inventoryDigest: options.inventoryDigest, + decisions: decisionsBoundToPlan( + review.decisions, + options.openRequirements, + ), + keyProvider: options.keyProvider, + createReceipt: (decisions) => + createLegacyCrontabAdoptionDecisionReceipt( + options.sourceClient, + options.timezone, + { + decisionId: options.decisionId, + profile: options.profile, + planDigest: options.automationPlanDigest, + inventoryDigest: options.inventoryDigest, + reviewer: options.reviewer, + issuedAtMs: options.issuedAtMs, + expiresAtMs: options.expiresAtMs, + }, + decisions, + ), + async confirmExternalAuthority() { + review.confirmIdentity(); + await options.confirmExternalAuthority(); + }, + }); + review.confirmIdentity(); + return Object.freeze({ + authorization, + reviewFileDigest: review.evidence.fileDigest, + }); + }, + ); + } catch (error) { + if (error instanceof ReconciliationAutomationDecisionError) throw error; + throw new ReconciliationAutomationDecisionError( + 'authorization could not be issued', + error, + ); + } +} + +export async function recoverReconciliationAutomationDecision( + options: RecoverReconciliationAutomationDecisionOptions, +): Promise> { + try { + return await withPrivateLegacyCrontabAdoptionDecisionReviewFile( + { + filePath: options.reviewFilePath, + expectedDecisionId: options.decisionId, + expectedProfile: options.profile, + expectedPlanDigest: options.automationPlanDigest, + expectedInventoryDigest: options.inventoryDigest, + }, + async (review) => + withVerifiedLegacyCrontabDecisionAuthorizationFile( + verifiedOptions(options, options.openRequirements), + async (authorization) => { + const reviewed = iterator( + decisionsBoundToPlan( + review.decisions, + options.openRequirements, + ), + 'review decision stream', + ); + const signed = iterator( + authorization.decisions, + 'signed decision stream', + ); + for (;;) { + const left = reviewed.next(); + const right = signed.next(); + if (left.done || right.done) { + if (left.done !== right.done) { + throw new ReconciliationAutomationDecisionError( + 'review and signed decision counts differ', + ); + } + break; + } + if (!sameDecision(left.value, right.value)) { + throw new ReconciliationAutomationDecisionError( + 'review decision differs from signed authorization', + ); + } + } + review.confirmIdentity(); + authorization.confirmIdentity(); + return Object.freeze({ + authorization: authorization.result, + reviewFileDigest: review.evidence.fileDigest, + }); + }, + ), + ); + } catch (error) { + if (error instanceof ReconciliationAutomationDecisionError) throw error; + throw new ReconciliationAutomationDecisionError( + 'authorization recovery failed', + error, + ); + } +} + +export async function withVerifiedReconciliationAutomationDecision( + options: ReconciliationAutomationDecisionVerificationOptions, + consumer: ( + scope: VerifiedReconciliationAutomationDecisionScope, + ) => T | Promise, +): Promise { + if (typeof consumer !== 'function') { + throw new ReconciliationAutomationDecisionError( + 'verified decision consumer is invalid', + ); + } + try { + return await withVerifiedLegacyCrontabDecisionAuthorizationFile( + verifiedOptions(options, options.openRequirements), + (scope) => + consumer( + Object.freeze({ + ...scope, + decisions: decisionsBoundToPlan( + scope.decisions, + options.openRequirements, + ), + }), + ), + ); + } catch (error) { + if (error instanceof ReconciliationAutomationDecisionError) throw error; + throw new ReconciliationAutomationDecisionError( + 'authorization verification failed', + error, + ); + } +} + +export async function verifyReconciliationAutomationDecision( + options: ReconciliationAutomationDecisionVerificationOptions, +): Promise { + return withVerifiedReconciliationAutomationDecision(options, (scope) => { + for (const _decision of scope.decisions) { + // Full consumption proves the second plan-bound stream as well. + } + scope.confirmIdentity(); + return scope.result; + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts index dc1cb314..118025f9 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/instanceLineage.ts @@ -32,6 +32,8 @@ export type LocalCutoverInstanceHeadState = | 'reconciliation_application_prepared' | 'reconciliation_application_planned' | 'reconciliation_automation_planned' + | 'reconciliation_automation_decision_prepared' + | 'reconciliation_automation_reviewed' | 'rollback_prepared' | 'legacy_restart_requested' | 'legacy_running' @@ -172,6 +174,8 @@ function parseHead(value: unknown): Readonly { head.state !== 'reconciliation_application_prepared' && head.state !== 'reconciliation_application_planned' && head.state !== 'reconciliation_automation_planned' && + head.state !== 'reconciliation_automation_decision_prepared' && + head.state !== 'reconciliation_automation_reviewed' && head.state !== 'rollback_prepared' && head.state !== 'legacy_restart_requested' && head.state !== 'legacy_running' && @@ -347,6 +351,8 @@ export function advanceLocalCutoverInstanceHead( | 'reconciliation_application_prepared' | 'reconciliation_application_planned' | 'reconciliation_automation_planned' + | 'reconciliation_automation_decision_prepared' + | 'reconciliation_automation_reviewed' | 'rollback_prepared' | 'legacy_restart_requested' | 'legacy_running' @@ -394,6 +400,8 @@ export function advanceLocalCutoverInstanceHead( current.state === 'reconciliation_application_prepared' || current.state === 'reconciliation_application_planned' || current.state === 'reconciliation_automation_planned' || + current.state === 'reconciliation_automation_decision_prepared' || + current.state === 'reconciliation_automation_reviewed' || current.state === 'legacy_restart_requested' || current.state === 'legacy_running' || current.state === 'legacy_ready') @@ -427,6 +435,10 @@ export function advanceLocalCutoverInstanceHead( current.state === 'reconciliation_application_prepared') || (state === 'reconciliation_automation_planned' && current.state === 'reconciliation_application_planned') || + (state === 'reconciliation_automation_decision_prepared' && + current.state === 'reconciliation_automation_planned') || + (state === 'reconciliation_automation_reviewed' && + current.state === 'reconciliation_automation_decision_prepared') || (state === 'rollback_prepared' && current.state === 'target_stopped') || (state === 'legacy_restart_requested' && current.state === 'rollback_prepared') || diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 15e5d33c..9a9785da 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -137,6 +137,14 @@ import { verifyLocalReconciliationAutomationPlan, verifyLocalReconciliationAutomationPlanCommandFile, } from './reconciliation/application/automation/coordinator'; +import { + commitLocalReconciliationAutomationDecision, + commitLocalReconciliationAutomationDecisionCommandFile, + prepareLocalReconciliationAutomationDecision, + prepareLocalReconciliationAutomationDecisionCommandFile, + verifyLocalReconciliationAutomationDecision, + verifyLocalReconciliationAutomationDecisionCommandFile, +} from './reconciliation/application/automation/decisionCoordinator'; export { commitLocalReconciliationPlan, @@ -163,6 +171,12 @@ export { planLocalReconciliationAutomationCommandFile, verifyLocalReconciliationAutomationPlan, verifyLocalReconciliationAutomationPlanCommandFile, + prepareLocalReconciliationAutomationDecision, + prepareLocalReconciliationAutomationDecisionCommandFile, + commitLocalReconciliationAutomationDecision, + commitLocalReconciliationAutomationDecisionCommandFile, + verifyLocalReconciliationAutomationDecision, + verifyLocalReconciliationAutomationDecisionCommandFile, }; export { @@ -203,6 +217,21 @@ export { export { type LocalReconciliationAutomationPlanDependencies, } from './reconciliation/application/automation/coordinator'; +export { + normalizeLocalReconciliationAutomationDecisionCommitCommand, + normalizeLocalReconciliationAutomationDecisionPrepareCommand, + normalizeLocalReconciliationAutomationDecisionVerifyCommand, + type LocalReconciliationAutomationDecisionCommitCommand, + type LocalReconciliationAutomationDecisionCommitOptions, + type LocalReconciliationAutomationDecisionOptions, + type LocalReconciliationAutomationDecisionPrepareCommand, + type LocalReconciliationAutomationDecisionPrepareResult, + type LocalReconciliationAutomationDecisionTerminalResult, + type LocalReconciliationAutomationDecisionVerifyCommand, +} from './reconciliation/application/automation/decisionContract'; +export { + type LocalReconciliationAutomationDecisionDependencies, +} from './reconciliation/application/automation/decisionCoordinator'; export { MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES, MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index 59550213..1700b8d9 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -27,6 +27,9 @@ import { verifyLocalReconciliationApplicationCommandFile, planLocalReconciliationAutomationCommandFile, verifyLocalReconciliationAutomationPlanCommandFile, + prepareLocalReconciliationAutomationDecisionCommandFile, + commitLocalReconciliationAutomationDecisionCommandFile, + verifyLocalReconciliationAutomationDecisionCommandFile, writeLocalReconciliationReviewDiagnosticsCommandFile, prepareLocalDeploymentCommandFile, proveLocalDeploymentLegacyReadinessCommandFile, @@ -42,7 +45,7 @@ import { } from './localDeployment'; const USAGE = - 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -86,6 +89,9 @@ async function main(argv: readonly string[]): Promise { argv[0] !== 'reconciliation-application-verify' && argv[0] !== 'reconciliation-automation-plan' && argv[0] !== 'reconciliation-automation-verify' && + argv[0] !== 'reconciliation-automation-decision-prepare' && + argv[0] !== 'reconciliation-automation-decision-commit' && + argv[0] !== 'reconciliation-automation-decision-verify' && argv[0] !== 'compose-revision' && argv[0] !== 'compose-preflight' && argv[0] !== 'compose-apply' && @@ -186,6 +192,12 @@ async function main(argv: readonly string[]): Promise { ? planLocalReconciliationAutomationCommandFile(argv[2]!) : argv[0] === 'reconciliation-automation-verify' ? verifyLocalReconciliationAutomationPlanCommandFile(argv[2]!) + : argv[0] === 'reconciliation-automation-decision-prepare' + ? prepareLocalReconciliationAutomationDecisionCommandFile(argv[2]!) + : argv[0] === 'reconciliation-automation-decision-commit' + ? commitLocalReconciliationAutomationDecisionCommandFile(argv[2]!) + : argv[0] === 'reconciliation-automation-decision-verify' + ? verifyLocalReconciliationAutomationDecisionCommandFile(argv[2]!) : argv[0] === 'compose-revision' ? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!) : argv[0] === 'compose-preflight' diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/coordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/coordinator.ts index d18c5884..65adfe31 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/coordinator.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/coordinator.ts @@ -609,6 +609,33 @@ function validateTerminalBinding( } } +export interface LocalReconciliationAutomationTerminal { + readonly receipt: Readonly; + readonly planPath: string; +} + +/** + * Re-opens the sealed row plan for the separately authenticated decision + * phase. This reader exposes no live database authority and never repairs a + * terminal bundle. + */ +export function readLocalReconciliationAutomationTerminal( + automationRoot: string, + automationId: string, + uid: number, +): Readonly { + const selected = automationPaths(automationRoot, automationId); + validateDirectory(selected.root, uid, [0o500], 'automation plan root'); + validateDirectory(selected.staging, uid, [0o500], 'automation staging'); + validateCatalog(selected, true); + const receipt = readReceipt(selected.receipt, uid, [0o400]); + if (receipt.automationId !== automationId) { + configurationError('automation terminal identity drifted'); + } + validatePlanFile(selected.plan, receipt, uid, [0o400]); + return Object.freeze({ receipt, planPath: selected.plan }); +} + export async function planLocalReconciliationAutomation( value: unknown, dependencies: LocalReconciliationAutomationPlanDependencies = {}, diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionContract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionContract.ts new file mode 100644 index 00000000..6545a743 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionContract.ts @@ -0,0 +1,437 @@ +import path from 'node:path'; + +import { currentIdentity } from '../../../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../../../foundation/error'; + +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const UUID_V4_PATTERN = + /^[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_PATTERN = + /^[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_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; +const MAX_PATH_BYTES = 4_096; +export const MAX_LOCAL_RECONCILIATION_AUTOMATION_DECISION_LIFETIME_MS = + 30 * 60 * 1_000; + +export interface LocalReconciliationAutomationDecisionOptions { + readonly deploymentRoot: string; + readonly applicationRoot: string; + readonly automationRoot: string; + readonly automationDecisionRoot: string; + readonly allowRootService: boolean; +} + +export interface LocalReconciliationAutomationDecisionPrepareCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.automation.decision.prepare'; + readonly options: Readonly; + readonly request: Readonly<{ + decisionId: string; + automationId: string; + expectedAutomationPlanDigest: string; + expectedHeadDigest: string; + preparedAtMs: number; + }>; +} + +export interface LocalReconciliationAutomationDecisionCommitOptions + extends LocalReconciliationAutomationDecisionOptions { + readonly targetDatabasePath: string; + readonly ownerPepperKeyringDirectory: string; + readonly credentialFilePath: string; + readonly busyTimeoutMs?: number; +} + +export interface LocalReconciliationAutomationDecisionCommitCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.automation.decision.commit'; + readonly options: Readonly; + readonly request: Readonly<{ + decisionId: string; + automationId: string; + expectedPreparationDigest: string; + expectedHeadDigest: string; + decisionFilePath: string; + committedAtMs: number; + authorizationLifetimeMs: number; + }>; +} + +export interface LocalReconciliationAutomationDecisionVerifyCommand { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.automation.decision.verify'; + readonly options: Readonly; + readonly request: Readonly<{ + decisionId: string; + automationId: string; + expectedDecisionDigest: string; + }>; +} + +export interface LocalReconciliationAutomationDecisionPrepareResult { + readonly schemaVersion: 1; + readonly operation: 'local.deployment.reconciliation.automation.decision.prepare'; + readonly status: 'prepared' | 'existing'; + readonly state: 'reconciliation_automation_decision_prepared'; + readonly decisionId: string; + readonly automationId: string; + readonly preparationDigest: string; + readonly instanceHeadDigest: string; +} + +export interface LocalReconciliationAutomationDecisionTerminalResult { + readonly schemaVersion: 1; + readonly operation: + | 'local.deployment.reconciliation.automation.decision.commit' + | 'local.deployment.reconciliation.automation.decision.verify'; + readonly status: 'prepared' | 'existing' | 'verified'; + readonly state: 'reconciliation_automation_reviewed'; + readonly decisionId: string; + readonly automationId: string; + readonly decisionDigest: string; + readonly signedDecisionSetDigest: string; + readonly rowCount: number; + readonly adoptedCount: number; + readonly skippedCount: number; + readonly instanceHeadDigest: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError( + `reconciliation automation decision ${message}`, + ); +} + +function object(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function 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_PATTERN.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + configurationError(`${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 descendant(root: string, candidate: string, label: string): void { + const relative = path.relative(root, candidate); + if ( + relative.length === 0 || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + configurationError(`${label} must be below deploymentRoot`); + } +} + +function digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + configurationError(`${label} must be a SHA-256 digest`); + } + return value; +} + +function automationId(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + configurationError('automationId must be a lowercase UUID v4'); + } + return value; +} + +function decisionId(value: unknown): string { + if (typeof value !== 'string' || !UUID_V7_PATTERN.test(value)) { + configurationError('decisionId must be a lowercase UUID v7'); + } + return value; +} + +function baseOptions( + value: unknown, +): Readonly { + const options = object(value, 'options'); + const keys = [ + 'allowRootService', + 'applicationRoot', + 'automationDecisionRoot', + 'automationRoot', + 'deploymentRoot', + ]; + exact(options, keys, 'options'); + const identity = currentIdentity(); + if ( + typeof options.allowRootService !== 'boolean' || + (identity.uid === 0) !== options.allowRootService + ) { + configurationError('command identity is invalid'); + } + const roots = keys + .filter((key) => key !== 'allowRootService') + .map((key) => safePath(options[key], key)); + for (let left = 0; left < roots.length; left += 1) { + for (let right = left + 1; right < roots.length; right += 1) { + if ( + overlaps(roots[left]!, roots[right]!) || + overlaps(roots[right]!, roots[left]!) + ) { + configurationError('authority roots overlap'); + } + } + } + return Object.freeze({ + deploymentRoot: safePath(options.deploymentRoot, 'deploymentRoot'), + applicationRoot: safePath(options.applicationRoot, 'applicationRoot'), + automationRoot: safePath(options.automationRoot, 'automationRoot'), + automationDecisionRoot: safePath( + options.automationDecisionRoot, + 'automationDecisionRoot', + ), + allowRootService: options.allowRootService as boolean, + }); +} + +function command(value: unknown, operation: string) { + const selected = object(value, 'command'); + exact(selected, ['operation', 'options', 'request', 'schemaVersion'], 'command'); + if (selected.schemaVersion !== 1 || selected.operation !== operation) { + configurationError('command version or operation is invalid'); + } + return Object.freeze({ + options: selected.options, + request: object(selected.request, 'request'), + }); +} + +export function normalizeLocalReconciliationAutomationDecisionPrepareCommand( + value: unknown, +): Readonly { + const selected = command( + value, + 'local.deployment.reconciliation.automation.decision.prepare', + ); + exact( + selected.request, + [ + 'automationId', + 'decisionId', + 'expectedAutomationPlanDigest', + 'expectedHeadDigest', + 'preparedAtMs', + ], + 'request', + ); + if ( + !Number.isSafeInteger(selected.request.preparedAtMs) || + (selected.request.preparedAtMs as number) < 0 + ) { + configurationError('preparedAtMs is invalid'); + } + return Object.freeze({ + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.prepare', + options: baseOptions(selected.options), + request: Object.freeze({ + decisionId: decisionId(selected.request.decisionId), + automationId: automationId(selected.request.automationId), + expectedAutomationPlanDigest: digest( + selected.request.expectedAutomationPlanDigest, + 'expectedAutomationPlanDigest', + ), + expectedHeadDigest: digest( + selected.request.expectedHeadDigest, + 'expectedHeadDigest', + ), + preparedAtMs: selected.request.preparedAtMs as number, + }), + }); +} + +export function normalizeLocalReconciliationAutomationDecisionCommitCommand( + value: unknown, +): Readonly { + const selected = command( + value, + 'local.deployment.reconciliation.automation.decision.commit', + ); + const options = object(selected.options, 'options'); + const hasBusyTimeout = Object.hasOwn(options, 'busyTimeoutMs'); + exact( + options, + [ + 'allowRootService', + 'applicationRoot', + 'automationDecisionRoot', + 'automationRoot', + 'credentialFilePath', + 'deploymentRoot', + 'ownerPepperKeyringDirectory', + 'targetDatabasePath', + ...(hasBusyTimeout ? ['busyTimeoutMs'] : []), + ], + 'options', + ); + const base = baseOptions({ + allowRootService: options.allowRootService, + applicationRoot: options.applicationRoot, + automationDecisionRoot: options.automationDecisionRoot, + automationRoot: options.automationRoot, + deploymentRoot: options.deploymentRoot, + }); + const targetDatabasePath = safePath( + options.targetDatabasePath, + 'targetDatabasePath', + ); + const ownerPepperKeyringDirectory = safePath( + options.ownerPepperKeyringDirectory, + 'ownerPepperKeyringDirectory', + ); + const credentialFilePath = safePath( + options.credentialFilePath, + 'credentialFilePath', + ); + for (const [candidate, label] of [ + [ownerPepperKeyringDirectory, 'ownerPepperKeyringDirectory'], + [credentialFilePath, 'credentialFilePath'], + ] as const) { + descendant(base.deploymentRoot, candidate, label); + } + if ( + options.busyTimeoutMs !== undefined && + (!Number.isSafeInteger(options.busyTimeoutMs) || + (options.busyTimeoutMs as number) < 1 || + (options.busyTimeoutMs as number) > 60_000) + ) { + configurationError('busyTimeoutMs is invalid'); + } + exact( + selected.request, + [ + 'authorizationLifetimeMs', + 'automationId', + 'committedAtMs', + 'decisionFilePath', + 'decisionId', + 'expectedHeadDigest', + 'expectedPreparationDigest', + ], + 'request', + ); + const decisionFilePath = safePath( + selected.request.decisionFilePath, + 'decisionFilePath', + ); + if ( + [ + base.deploymentRoot, + base.applicationRoot, + base.automationRoot, + base.automationDecisionRoot, + ].some( + (root) => + overlaps(root, decisionFilePath) || overlaps(decisionFilePath, root), + ) + ) { + configurationError('decisionFilePath overlaps an authority root'); + } + if ( + !Number.isSafeInteger(selected.request.committedAtMs) || + (selected.request.committedAtMs as number) < 0 || + !Number.isSafeInteger(selected.request.authorizationLifetimeMs) || + (selected.request.authorizationLifetimeMs as number) < 1 || + (selected.request.authorizationLifetimeMs as number) > + MAX_LOCAL_RECONCILIATION_AUTOMATION_DECISION_LIFETIME_MS + ) { + configurationError('decision timestamp or lifetime is invalid'); + } + return Object.freeze({ + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.commit', + options: Object.freeze({ + ...base, + targetDatabasePath, + ownerPepperKeyringDirectory, + credentialFilePath, + ...(options.busyTimeoutMs === undefined + ? {} + : { busyTimeoutMs: options.busyTimeoutMs as number }), + }), + request: Object.freeze({ + decisionId: decisionId(selected.request.decisionId), + automationId: automationId(selected.request.automationId), + expectedPreparationDigest: digest( + selected.request.expectedPreparationDigest, + 'expectedPreparationDigest', + ), + expectedHeadDigest: digest( + selected.request.expectedHeadDigest, + 'expectedHeadDigest', + ), + decisionFilePath, + committedAtMs: selected.request.committedAtMs as number, + authorizationLifetimeMs: + selected.request.authorizationLifetimeMs as number, + }), + }); +} + +export function normalizeLocalReconciliationAutomationDecisionVerifyCommand( + value: unknown, +): Readonly { + const selected = command( + value, + 'local.deployment.reconciliation.automation.decision.verify', + ); + exact( + selected.request, + ['automationId', 'decisionId', 'expectedDecisionDigest'], + 'request', + ); + return Object.freeze({ + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.verify', + options: baseOptions(selected.options), + request: Object.freeze({ + decisionId: decisionId(selected.request.decisionId), + automationId: automationId(selected.request.automationId), + expectedDecisionDigest: digest( + selected.request.expectedDecisionDigest, + 'expectedDecisionDigest', + ), + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionCoordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionCoordinator.ts new file mode 100644 index 00000000..ba646e59 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionCoordinator.ts @@ -0,0 +1,1042 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + issueReconciliationAutomationDecision, + recoverReconciliationAutomationDecision, + verifyReconciliationAutomationDecision, + type ReconciliationAutomationDecisionPublication, +} 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 { 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 { cutoverDigest } from '../../../cutover/targetEvidence'; +import { readLocalReconciliationCaptureIntent } from '../../preparation'; +import { readLocalReconciliationPlanTerminal } from '../../planning/preparation'; +import { + withLocalReconciliationSealedDatabaseAsync, + type LocalReconciliationSealedBundleReaderDependencies, +} from '../../sealed-bundle/reader'; +import { readLocalReconciliationApplicationTerminal } from '../coordinator'; +import { + normalizeLocalReconciliationAutomationDecisionCommitCommand, + normalizeLocalReconciliationAutomationDecisionPrepareCommand, + normalizeLocalReconciliationAutomationDecisionVerifyCommand, + type LocalReconciliationAutomationDecisionCommitCommand, + type LocalReconciliationAutomationDecisionPrepareCommand, + type LocalReconciliationAutomationDecisionPrepareResult, + type LocalReconciliationAutomationDecisionTerminalResult, +} from './decisionContract'; +import { + buildLocalReconciliationAutomationDecisionIntent, + buildLocalReconciliationAutomationDecisionReceipt, + localReconciliationAutomationDecisionEvidenceContents, + normalizeLocalReconciliationAutomationDecisionIntent, + normalizeLocalReconciliationAutomationDecisionReceipt, + type LocalReconciliationAutomationDecisionIntent, + type LocalReconciliationAutomationDecisionReceipt, +} from './decisionEvidence'; +import { + readLocalReconciliationAutomationTerminal, + type LocalReconciliationAutomationTerminal, +} from './coordinator'; +import { + createLocalReconciliationAutomationRequirementFactory, + readLocalReconciliationAutomationPlanHeader, +} from './planReader'; +import { + ensureLocalReconciliationReviewIssuerKeyring, + LocalReconciliationReviewIssuerKeyringFileProvider, +} from '../../review/issuerKeyring'; + +const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000; +const COMMIT_CLOCK_SKEW_MS = 60_000; + +interface DecisionPaths { + readonly root: string; + readonly staging: string; + readonly intent: string; + readonly authorization: string; + readonly receipt: string; +} + +interface DecisionContext { + readonly automation: Readonly; + readonly application: Awaited< + ReturnType + >; + readonly header: ReturnType< + typeof readLocalReconciliationAutomationPlanHeader + >; +} + +type AuthenticationDatabase = Awaited< + ReturnType +>; + +export interface LocalReconciliationAutomationDecisionDependencies + extends LocalReconciliationSealedBundleReaderDependencies { + readonly openAuthenticationDatabase?: typeof openLocalSqliteAuthenticationReadDatabase; + readonly authenticate?: typeof establishAuthenticatedLocalCommand; + readonly now?: () => number; + readonly afterHeadPrepared?: () => void; + readonly afterAuthorizationPublished?: () => void; + readonly afterReceiptPublished?: () => void; + readonly afterTerminalSealed?: () => void; + readonly afterHeadAdvanced?: () => void; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `reconciliation automation decision ${message}`, + { cause }, + ); +} + +function paths( + decisionRoot: string, + automationId: string, +): Readonly { + const root = path.join(decisionRoot, automationId); + return Object.freeze({ + root, + staging: path.join(root, 'staging'), + intent: path.join(root, 'intent.json'), + authorization: path.join(root, 'authorization.ndjson'), + receipt: path.join(root, 'receipt.json'), + }); +} + +function validateDirectory( + directory: string, + uid: number, + modes: readonly number[], + label: string, +): number { + let stat: fs.Stats; + try { + stat = fs.lstatSync(directory); + } catch (error) { + return configurationError(`${label} is unavailable`, error); + } + const mode = stat.mode & 0o777; + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== uid || + !modes.includes(mode) || + fs.realpathSync(directory) !== directory + ) { + configurationError(`${label} identity is invalid`); + } + return mode; +} + +function validateCatalog(selected: Readonly, 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', + ] + : []), + ]); + for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) { + if (!allowed.has(entry.name) || entry.isSymbolicLink()) { + configurationError('decision root contains unknown material'); + } + } + if (fs.readdirSync(selected.staging).length !== 0) { + configurationError('decision staging must remain empty'); + } +} + +function terminalJson( + filePath: string, + uid: number, + allowedModes: readonly number[], +): unknown { + let descriptor: number | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + !allowedModes.includes(Number(before.mode) & 0o777) || + before.nlink !== 1n || + before.size < 2n || + before.size > 64n * 1024n + ) { + configurationError('terminal JSON identity is invalid'); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size || + opened.mtimeNs !== before.mtimeNs || + opened.ctimeNs !== before.ctimeNs + ) { + configurationError('terminal JSON changed while opening'); + } + const bytes = Buffer.alloc(Number(opened.size)); + try { + let offset = 0; + while (offset < bytes.length) { + const read = fs.readSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + if (read < 1) configurationError('terminal JSON read stalled'); + offset += read; + } + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + after.dev !== opened.dev || + after.ino !== opened.ino || + after.size !== opened.size || + after.mtimeNs !== opened.mtimeNs || + after.ctimeNs !== opened.ctimeNs + ) { + configurationError('terminal JSON drifted while reading'); + } + return JSON.parse(bytes.toString('utf8')) as unknown; + } finally { + bytes.fill(0); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('terminal JSON cannot be read', error); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +async function context( + options: Readonly, + automationId: string, + uid: number, +): Promise> { + const automation = readLocalReconciliationAutomationTerminal( + options.automationRoot, + automationId, + uid, + ); + const application = await readLocalReconciliationApplicationTerminal( + options.applicationRoot, + automation.receipt.applicationId, + uid, + ); + const header = readLocalReconciliationAutomationPlanHeader( + automation.planPath, + automation.receipt, + uid, + ); + if ( + application.intent.command.options.deploymentRoot !== + options.deploymentRoot || + application.intent.command.options.applicationRoot !== + options.applicationRoot || + automation.receipt.applicationPlanDigest !== + application.plan.applicationPlanDigest || + header.applicationPlanDigest !== application.plan.applicationPlanDigest || + header.profile !== application.intent.profile + ) { + configurationError('plan is detached from its application authority'); + } + return Object.freeze({ automation, application, header }); +} + +function intentBinding( + intent: Readonly, + selected: Readonly, +): void { + if ( + intent.command.request.automationId !== + selected.automation.receipt.automationId || + intent.command.request.expectedAutomationPlanDigest !== + selected.automation.receipt.automationPlanDigest || + intent.applicationId !== selected.application.plan.applicationId || + intent.applicationPlanDigest !== + selected.application.plan.applicationPlanDigest || + intent.legacyInventoryDigest !== + selected.automation.receipt.legacyInventoryDigest || + intent.profile !== selected.header.profile || + intent.projectId !== selected.header.projectId || + intent.legacyTimezone !== selected.header.legacyTimezone || + intent.instanceId !== selected.application.intent.instanceId || + intent.cutoverId !== selected.application.intent.cutoverId || + intent.activationDigest !== selected.application.intent.activationDigest || + intent.generation !== selected.application.intent.generation + ) { + configurationError('decision intent binding drifted'); + } +} + +function readIntent( + selected: Readonly, + uid: number, + allowedModes: readonly number[], +): Readonly { + return normalizeLocalReconciliationAutomationDecisionIntent( + terminalJson(selected.intent, uid, allowedModes), + ); +} + +function readReceipt( + selected: Readonly, + uid: number, + allowedModes: readonly number[], +): Readonly { + return normalizeLocalReconciliationAutomationDecisionReceipt( + terminalJson(selected.receipt, uid, allowedModes), + ); +} + +function prepareResult( + status: 'prepared' | 'existing', + intent: Readonly, + head: Readonly, +): Readonly { + return Object.freeze({ + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.prepare', + status, + state: 'reconciliation_automation_decision_prepared', + decisionId: intent.command.request.decisionId, + automationId: intent.command.request.automationId, + preparationDigest: intent.preparationDigest, + instanceHeadDigest: head.headDigest, + }); +} + +function terminalResult( + operation: LocalReconciliationAutomationDecisionTerminalResult['operation'], + status: LocalReconciliationAutomationDecisionTerminalResult['status'], + receipt: Readonly, + head: Readonly, +): Readonly { + return Object.freeze({ + schemaVersion: 1, + operation, + status, + state: 'reconciliation_automation_reviewed', + decisionId: receipt.decisionId, + automationId: receipt.automationId, + decisionDigest: receipt.decisionDigest, + signedDecisionSetDigest: receipt.signedDecisionSetDigest, + rowCount: receipt.rowCount, + adoptedCount: receipt.adoptedCount, + skippedCount: receipt.skippedCount, + instanceHeadDigest: head.headDigest, + }); +} + +function advanceHead( + intent: Readonly, + state: + | 'reconciliation_automation_decision_prepared' + | 'reconciliation_automation_reviewed', + sourceRecordDigest: string, + requestedAtMs: number, + uid: number, +): Readonly { + return advanceLocalCutoverInstanceHead( + { + options: { + deploymentRoot: intent.command.options.deploymentRoot, + }, + request: { + cutoverId: intent.cutoverId, + profile: intent.profile, + instanceId: intent.instanceId, + expectedActivationDigest: intent.activationDigest, + requestedAtMs, + }, + }, + uid, + state, + intent.generation, + sourceRecordDigest, + ); +} + +export async function prepareLocalReconciliationAutomationDecision( + value: unknown, + dependencies: LocalReconciliationAutomationDecisionDependencies = {}, +): Promise> { + const command = + normalizeLocalReconciliationAutomationDecisionPrepareCommand(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'], + ] as const) { + validatePrivateDirectory(directory, uid, label); + } + const current = await context( + command.options, + command.request.automationId, + uid, + ); + if ( + current.automation.receipt.automationPlanDigest !== + command.request.expectedAutomationPlanDigest + ) { + configurationError('expected automation plan digest drifted'); + } + const intent = buildLocalReconciliationAutomationDecisionIntent({ + command, + applicationId: current.application.plan.applicationId, + applicationPlanDigest: current.application.plan.applicationPlanDigest, + legacyInventoryDigest: current.automation.receipt.legacyInventoryDigest, + profile: current.header.profile, + projectId: current.header.projectId, + legacyTimezone: current.header.legacyTimezone, + instanceId: current.application.intent.instanceId, + cutoverId: current.application.intent.cutoverId, + activationDigest: current.application.intent.activationDigest, + generation: current.application.intent.generation, + }); + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + intentBinding(intent, current); + if ( + (head.state === 'reconciliation_automation_planned' && + (head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.command.request.expectedAutomationPlanDigest)) || + (head.state === 'reconciliation_automation_decision_prepared' && + head.sourceRecordDigest !== intent.preparationDigest) || + (head.state !== 'reconciliation_automation_planned' && + head.state !== 'reconciliation_automation_decision_prepared') + ) { + configurationError('decision prepare lost instance head compare-and-swap'); + } + const selected = paths( + command.options.automationDecisionRoot, + command.request.automationId, + ); + ensurePrivateDirectory(selected.root, uid, 'automationDecisionDirectory'); + ensurePrivateDirectory(selected.staging, uid, 'automationDecisionStaging'); + validateCatalog(selected, false); + const contents = localReconciliationAutomationDecisionEvidenceContents(intent); + preflightPublishedFile( + selected.intent, + contents, + 0o600, + uid, + 'automation decision intent', + ); + const next = + head.state === 'reconciliation_automation_decision_prepared' + ? head + : advanceHead( + intent, + 'reconciliation_automation_decision_prepared', + intent.preparationDigest, + command.request.preparedAtMs, + uid, + ); + dependencies.afterHeadPrepared?.(); + const status = publishExactFile( + selected.intent, + contents, + 0o600, + uid, + 'automation decision intent', + ); + validateCatalog(selected, false); + return prepareResult(status, intent, next); +} + +function validateCommitBinding( + command: Readonly, + intent: Readonly, +): void { + const prepared = intent.command; + if ( + prepared.options.deploymentRoot !== command.options.deploymentRoot || + prepared.options.applicationRoot !== command.options.applicationRoot || + prepared.options.automationRoot !== command.options.automationRoot || + prepared.options.automationDecisionRoot !== + command.options.automationDecisionRoot || + prepared.options.allowRootService !== command.options.allowRootService || + prepared.request.decisionId !== command.request.decisionId || + prepared.request.automationId !== command.request.automationId || + intent.preparationDigest !== command.request.expectedPreparationDigest + ) { + configurationError('commit command is detached from prepared intent'); + } +} + +function strongReviewer( + authenticated: Readonly, + original: Readonly, + committedAtMs: number, +) { + const principal = authenticated.principal; + if ( + principal.subject.type !== 'user' || + principal.subject.type !== original.subject.type || + principal.subject.id !== original.subject.id || + !['hardware', 'local_console', 'multi_factor'].includes( + principal.assurance, + ) || + principal.authenticatedAtMs > committedAtMs || + committedAtMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS || + principal.expiresAtMs <= committedAtMs + ) { + configurationError( + 'decision commit requires the same recently strong authenticated User', + ); + } + return principal; +} + +function planTerminal(selected: Readonly) { + return readLocalReconciliationPlanTerminal( + selected.application.intent.command.options.planRoot, + selected.application.review.intent.command.request.planId, + currentIdentity().uid, + ); +} + +function buildReceipt( + intent: Readonly, + preparedHeadDigest: string, + publication: Readonly, +): Readonly { + const signed = publication.authorization.receipt; + const adoptedCount = + signed.decisions.dispositions.adopt + + signed.decisions.dispositions.adopt_shell_compatibility; + return buildLocalReconciliationAutomationDecisionReceipt({ + decisionId: signed.decisionId, + automationId: intent.command.request.automationId, + automationPlanDigest: signed.planDigest, + legacyInventoryDigest: signed.inventoryDigest, + preparedHeadDigest, + authorizationFileDigest: publication.authorization.file.fileDigest, + signedReceiptDigest: signed.receiptDigest, + signedDecisionSetDigest: signed.decisions.decisionDigest, + reviewFileDigest: publication.reviewFileDigest, + reviewerDigest: cutoverDigest({ + subject: signed.reviewer.subject, + authenticationId: signed.reviewer.authenticationId, + authenticatedAtMs: signed.reviewer.authenticatedAtMs, + assurance: signed.reviewer.assurance, + }), + rowCount: signed.decisions.rowCount, + adoptedCount, + skippedCount: signed.decisions.dispositions.skip, + issuedAtMs: signed.issuedAtMs, + expiresAtMs: signed.expiresAtMs, + }); +} + +function validateReceiptBinding( + receipt: Readonly, + intent: Readonly, + publication: Readonly, +): void { + const expected = buildReceipt( + intent, + receipt.preparedHeadDigest, + publication, + ); + if (expected.decisionDigest !== receipt.decisionDigest) { + configurationError('terminal receipt is detached from authorization'); + } +} + +async function authorization( + command: Readonly, + intent: Readonly, + selectedPaths: Readonly, + selected: Readonly, + dependencies: LocalReconciliationAutomationDecisionDependencies, + uid: number, +): Promise> { + const terminal = planTerminal(selected); + const capture = readLocalReconciliationCaptureIntent( + selected.application.intent.command.options.captureRoot, + terminal.plan.captureId, + ); + if ( + capture.command.request.targetDatabasePath !== + command.options.targetDatabasePath + ) { + configurationError('authentication database is detached from capture'); + } + const openRequirements = createLocalReconciliationAutomationRequirementFactory( + selected.automation.planPath, + selected.automation.receipt, + uid, + ); + const keyringPath = + selected.application.intent.command.options.issuerKeyringPath; + ensureLocalReconciliationReviewIssuerKeyring(keyringPath); + const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider( + keyringPath, + ); + const publication = await withLocalReconciliationSealedDatabaseAsync( + terminal.bundle, + 'legacy', + uid, + dependencies, + async (sourceClient) => { + const common = { + authorizationPath: selectedPaths.authorization, + decisionId: command.request.decisionId, + profile: intent.profile, + automationPlanDigest: + intent.command.request.expectedAutomationPlanDigest, + inventoryDigest: intent.legacyInventoryDigest, + sourceClient, + timezone: intent.legacyTimezone, + keyProvider, + observedAtMs: command.request.committedAtMs, + openRequirements, + allowedModes: [0o600, 0o400] as const, + allowedParentModes: [0o700] as const, + }; + if (fs.existsSync(selectedPaths.authorization)) { + return recoverReconciliationAutomationDecision({ + ...common, + reviewFilePath: command.request.decisionFilePath, + }); + } + const now = (dependencies.now ?? Date.now)(); + if ( + !Number.isSafeInteger(now) || + Math.abs(now - command.request.committedAtMs) > COMMIT_CLOCK_SKEW_MS + ) { + configurationError('commit timestamp is outside its clock window'); + } + const openDatabase = + dependencies.openAuthenticationDatabase ?? + openLocalSqliteAuthenticationReadDatabase; + const database: AuthenticationDatabase = await openDatabase({ + databasePath: command.options.targetDatabasePath, + profile: intent.profile, + ...(command.options.busyTimeoutMs === undefined + ? {} + : { busyTimeoutMs: command.options.busyTimeoutMs }), + }); + try { + const authenticate = + dependencies.authenticate ?? establishAuthenticatedLocalCommand; + const authenticated = await authenticate(database, { + deploymentRoot: command.options.deploymentRoot, + databasePath: command.options.targetDatabasePath, + ownerPepperKeyringDirectory: + command.options.ownerPepperKeyringDirectory, + credentialFilePath: command.options.credentialFilePath, + authenticationNamespace: 'local_reconciliation_automation', + now: () => command.request.committedAtMs, + }); + const reviewer = strongReviewer( + authenticated, + selected.application.review.authorization.header.reviewer, + command.request.committedAtMs, + ); + return issueReconciliationAutomationDecision({ + ...common, + reviewFilePath: command.request.decisionFilePath, + reviewer, + issuedAtMs: command.request.committedAtMs, + expiresAtMs: + command.request.committedAtMs + + command.request.authorizationLifetimeMs, + async confirmExternalAuthority() { + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + if ( + head.state !== 'reconciliation_automation_decision_prepared' || + head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('decision authority lost prepared head'); + } + await authenticated.confirm(); + }, + }); + } finally { + await database.close(); + } + }, + ); + if (publication === null) { + return configurationError('legacy database requires manual handling'); + } + return publication; +} + +function sealFile(filePath: string, uid: number): void { + let descriptor: number | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + ![0o600, 0o400].includes(Number(before.mode) & 0o777) || + before.nlink !== 1n + ) { + configurationError('terminal file cannot be sealed'); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + configurationError('terminal file changed while sealing'); + } + if ((Number(opened.mode) & 0o777) !== 0o400) { + fs.fchmodSync(descriptor, 0o400); + } + fs.fsyncSync(descriptor); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function sealDirectory(directory: string, uid: number): void { + const mode = validateDirectory(directory, uid, [0o700, 0o500], 'directory'); + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY); + try { + if (mode !== 0o500) fs.fchmodSync(descriptor, 0o500); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function sealTerminal(selected: Readonly, uid: number): void { + sealFile(selected.intent, uid); + sealFile(selected.authorization, uid); + sealFile(selected.receipt, uid); + sealDirectory(selected.staging, uid); + sealDirectory(selected.root, uid); + validateCatalog(selected, true); +} + +async function verifyPublication( + selectedPaths: Readonly, + intent: Readonly, + selected: Readonly, + receipt: Readonly, + dependencies: LocalReconciliationAutomationDecisionDependencies, + uid: number, + allowedModes: readonly (0o400 | 0o600)[], + allowedParentModes: readonly (0o500 | 0o700)[], +): Promise> { + const terminal = planTerminal(selected); + const openRequirements = createLocalReconciliationAutomationRequirementFactory( + selected.automation.planPath, + selected.automation.receipt, + uid, + ); + const keyProvider = new LocalReconciliationReviewIssuerKeyringFileProvider( + selected.application.intent.command.options.issuerKeyringPath, + ); + const publication = await withLocalReconciliationSealedDatabaseAsync( + terminal.bundle, + 'legacy', + uid, + dependencies, + async (sourceClient) => { + const authorization = await verifyReconciliationAutomationDecision({ + authorizationPath: selectedPaths.authorization, + decisionId: receipt.decisionId, + profile: intent.profile, + automationPlanDigest: receipt.automationPlanDigest, + inventoryDigest: receipt.legacyInventoryDigest, + sourceClient, + timezone: intent.legacyTimezone, + keyProvider, + observedAtMs: receipt.issuedAtMs, + openRequirements, + allowedModes, + allowedParentModes, + }); + return Object.freeze({ + authorization, + reviewFileDigest: receipt.reviewFileDigest, + }); + }, + ); + if (publication === null) { + return configurationError('legacy database requires manual handling'); + } + return publication; +} + +export async function commitLocalReconciliationAutomationDecision( + value: unknown, + dependencies: LocalReconciliationAutomationDecisionDependencies = {}, +): Promise> { + const command = + normalizeLocalReconciliationAutomationDecisionCommitCommand(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'], + ] as const) { + validatePrivateDirectory(directory, uid, label); + } + const selectedPaths = paths( + command.options.automationDecisionRoot, + command.request.automationId, + ); + validateDirectory(selectedPaths.root, uid, [0o700, 0o500], 'decision root'); + validateDirectory( + selectedPaths.staging, + uid, + [0o700, 0o500], + 'decision staging', + ); + const intent = readIntent(selectedPaths, uid, [0o600, 0o400]); + validateCommitBinding(command, intent); + const selected = await context( + intent.command.options, + intent.command.request.automationId, + uid, + ); + intentBinding(intent, selected); + let head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + if (fs.existsSync(selectedPaths.receipt)) { + validateCatalog(selectedPaths, false); + const receipt = readReceipt(selectedPaths, uid, [0o600, 0o400]); + if ( + receipt.decisionId !== command.request.decisionId || + receipt.automationId !== command.request.automationId || + receipt.preparedHeadDigest !== command.request.expectedHeadDigest || + receipt.issuedAtMs !== command.request.committedAtMs || + receipt.expiresAtMs !== + command.request.committedAtMs + + command.request.authorizationLifetimeMs + ) { + configurationError('terminal receipt is not an exact command replay'); + } + const publication = await verifyPublication( + selectedPaths, + intent, + selected, + receipt, + dependencies, + uid, + [0o600, 0o400], + [0o700, 0o500], + ); + validateReceiptBinding(receipt, intent, publication); + const existing = head.state === 'reconciliation_automation_reviewed'; + if ( + (!existing && + (head.state !== 'reconciliation_automation_decision_prepared' || + head.sourceRecordDigest !== intent.preparationDigest)) || + (existing && head.sourceRecordDigest !== receipt.decisionDigest) + ) { + configurationError('terminal receipt lost instance head binding'); + } + sealTerminal(selectedPaths, uid); + dependencies.afterTerminalSealed?.(); + head = existing + ? head + : advanceHead( + intent, + 'reconciliation_automation_reviewed', + receipt.decisionDigest, + receipt.issuedAtMs, + uid, + ); + dependencies.afterHeadAdvanced?.(); + return terminalResult( + command.operation, + existing ? 'existing' : 'prepared', + receipt, + head, + ); + } + if ( + head.state !== 'reconciliation_automation_decision_prepared' || + head.headDigest !== command.request.expectedHeadDigest || + head.sourceRecordDigest !== intent.preparationDigest + ) { + configurationError('decision commit lost prepared head compare-and-swap'); + } + validateCatalog(selectedPaths, false); + const publication = await authorization( + command, + intent, + selectedPaths, + selected, + dependencies, + uid, + ); + dependencies.afterAuthorizationPublished?.(); + const receipt = buildReceipt( + intent, + command.request.expectedHeadDigest, + publication, + ); + publishExactFile( + selectedPaths.receipt, + localReconciliationAutomationDecisionEvidenceContents(receipt), + 0o600, + uid, + 'automation decision receipt', + ); + dependencies.afterReceiptPublished?.(); + validateReceiptBinding(receipt, intent, publication); + sealTerminal(selectedPaths, uid); + dependencies.afterTerminalSealed?.(); + head = advanceHead( + intent, + 'reconciliation_automation_reviewed', + receipt.decisionDigest, + receipt.issuedAtMs, + uid, + ); + dependencies.afterHeadAdvanced?.(); + return terminalResult(command.operation, 'prepared', receipt, head); +} + +export async function verifyLocalReconciliationAutomationDecision( + value: unknown, + dependencies: LocalReconciliationAutomationDecisionDependencies = {}, +): Promise> { + const command = + normalizeLocalReconciliationAutomationDecisionVerifyCommand(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'], + ] as const) { + validatePrivateDirectory(directory, uid, label); + } + const selectedPaths = paths( + command.options.automationDecisionRoot, + command.request.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.decisionId !== command.request.decisionId || + receipt.decisionId !== command.request.decisionId || + receipt.automationId !== command.request.automationId || + receipt.decisionDigest !== command.request.expectedDecisionDigest + ) { + configurationError('verify command is detached from terminal decision'); + } + const selected = await context( + intent.command.options, + intent.command.request.automationId, + uid, + ); + intentBinding(intent, selected); + const publication = await verifyPublication( + selectedPaths, + intent, + selected, + receipt, + dependencies, + uid, + [0o400], + [0o500], + ); + validateReceiptBinding(receipt, intent, publication); + const head = readLocalCutoverInstanceHead( + command.options.deploymentRoot, + intent.instanceId, + uid, + ); + if ( + head.state !== 'reconciliation_automation_reviewed' || + head.sourceRecordDigest !== receipt.decisionDigest + ) { + configurationError('terminal decision is detached from instance head'); + } + return terminalResult(command.operation, 'verified', receipt, head); +} + +export function prepareLocalReconciliationAutomationDecisionCommandFile( + filePath: string, + dependencies: LocalReconciliationAutomationDecisionDependencies = {}, +) { + return prepareLocalReconciliationAutomationDecision( + readPrivateLocalCommandFile(filePath), + dependencies, + ); +} + +export function commitLocalReconciliationAutomationDecisionCommandFile( + filePath: string, + dependencies: LocalReconciliationAutomationDecisionDependencies = {}, +) { + return commitLocalReconciliationAutomationDecision( + readPrivateLocalCommandFile(filePath), + dependencies, + ); +} + +export function verifyLocalReconciliationAutomationDecisionCommandFile( + filePath: string, +) { + return verifyLocalReconciliationAutomationDecision( + readPrivateLocalCommandFile(filePath), + ); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionEvidence.ts new file mode 100644 index 00000000..a3eb52f9 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/decisionEvidence.ts @@ -0,0 +1,239 @@ +import { + normalizeLocalReconciliationAutomationDecisionPrepareCommand, + type LocalReconciliationAutomationDecisionPrepareCommand, +} from './decisionContract'; +import { LocalDeploymentConfigurationError } from '../../../foundation/error'; +import { cutoverDigest } from '../../../cutover/targetEvidence'; + +const INTENT_SCHEMA = + 'qinglong3-local-reconciliation-automation-decision-intent'; +const RECEIPT_SCHEMA = + 'qinglong3-local-reconciliation-automation-decision-receipt'; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +export interface LocalReconciliationAutomationDecisionIntent { + readonly schema: typeof INTENT_SCHEMA; + readonly schemaVersion: 1; + readonly command: Readonly; + readonly applicationId: string; + readonly applicationPlanDigest: string; + readonly legacyInventoryDigest: string; + readonly profile: 'edge' | 'standalone'; + readonly projectId: string; + readonly legacyTimezone: string | null; + readonly instanceId: string; + readonly cutoverId: string; + readonly activationDigest: string; + readonly generation: number; + readonly preparationDigest: string; +} + +export interface LocalReconciliationAutomationDecisionReceipt { + readonly schema: typeof RECEIPT_SCHEMA; + readonly schemaVersion: 1; + readonly state: 'reconciliation_automation_reviewed'; + readonly decisionId: string; + readonly automationId: string; + readonly automationPlanDigest: string; + readonly legacyInventoryDigest: string; + readonly preparedHeadDigest: string; + readonly authorizationFileDigest: string; + readonly signedReceiptDigest: string; + readonly signedDecisionSetDigest: string; + readonly reviewFileDigest: string; + readonly reviewerDigest: string; + readonly rowCount: number; + readonly adoptedCount: number; + readonly skippedCount: number; + readonly issuedAtMs: number; + readonly expiresAtMs: number; + readonly decisionDigest: string; +} + +function configurationError(message: string): never { + throw new LocalDeploymentConfigurationError( + `reconciliation automation decision evidence ${message}`, + ); +} + +function exact( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } + return record; +} + +export function buildLocalReconciliationAutomationDecisionIntent( + input: Omit, +): Readonly { + const payload = Object.freeze({ + schema: INTENT_SCHEMA, + schemaVersion: 1 as const, + ...input, + }); + return Object.freeze({ + ...payload, + preparationDigest: cutoverDigest(payload), + }); +} + +export function normalizeLocalReconciliationAutomationDecisionIntent( + value: unknown, +): Readonly { + const intent = exact( + value, + [ + 'activationDigest', + 'applicationId', + 'applicationPlanDigest', + 'command', + 'cutoverId', + 'generation', + 'instanceId', + 'legacyInventoryDigest', + 'legacyTimezone', + 'preparationDigest', + 'profile', + 'projectId', + 'schema', + 'schemaVersion', + ], + 'intent', + ); + const { preparationDigest, ...payload } = intent; + const normalizedCommand = + normalizeLocalReconciliationAutomationDecisionPrepareCommand( + intent.command, + ); + const normalizedPayload = Object.freeze({ + ...payload, + command: normalizedCommand, + }); + if ( + intent.schema !== INTENT_SCHEMA || + intent.schemaVersion !== 1 || + typeof preparationDigest !== 'string' || + !DIGEST_PATTERN.test(preparationDigest) || + cutoverDigest(payload) !== preparationDigest || + cutoverDigest(normalizedPayload) !== preparationDigest || + (intent.profile !== 'edge' && intent.profile !== 'standalone') || + ![intent.applicationPlanDigest, intent.legacyInventoryDigest, intent.activationDigest].every( + (digest) => typeof digest === 'string' && DIGEST_PATTERN.test(digest), + ) || + typeof intent.applicationId !== 'string' || + typeof intent.instanceId !== 'string' || + typeof intent.cutoverId !== 'string' || + typeof intent.projectId !== 'string' || + (intent.legacyTimezone !== null && + typeof intent.legacyTimezone !== 'string') || + !Number.isSafeInteger(intent.generation) || + (intent.generation as number) < 1 + ) { + configurationError('intent binding is invalid'); + } + return Object.freeze({ + ...normalizedPayload, + preparationDigest, + }) as unknown as Readonly; +} + +export function buildLocalReconciliationAutomationDecisionReceipt( + input: Omit, +): Readonly { + const payload = Object.freeze({ + schema: RECEIPT_SCHEMA, + schemaVersion: 1 as const, + state: 'reconciliation_automation_reviewed' as const, + ...input, + }); + return Object.freeze({ + ...payload, + decisionDigest: cutoverDigest(payload), + }); +} + +export function normalizeLocalReconciliationAutomationDecisionReceipt( + value: unknown, +): Readonly { + const receipt = exact( + value, + [ + 'adoptedCount', + 'authorizationFileDigest', + 'automationId', + 'automationPlanDigest', + 'decisionDigest', + 'decisionId', + 'expiresAtMs', + 'issuedAtMs', + 'legacyInventoryDigest', + 'preparedHeadDigest', + 'reviewFileDigest', + 'reviewerDigest', + 'rowCount', + 'schema', + 'schemaVersion', + 'signedDecisionSetDigest', + 'signedReceiptDigest', + 'skippedCount', + 'state', + ], + 'receipt', + ); + const { decisionDigest, ...payload } = receipt; + if ( + receipt.schema !== RECEIPT_SCHEMA || + receipt.schemaVersion !== 1 || + receipt.state !== 'reconciliation_automation_reviewed' || + typeof decisionDigest !== 'string' || + !DIGEST_PATTERN.test(decisionDigest) || + cutoverDigest(payload) !== decisionDigest || + ![ + receipt.automationPlanDigest, + receipt.legacyInventoryDigest, + receipt.preparedHeadDigest, + receipt.authorizationFileDigest, + receipt.signedReceiptDigest, + receipt.signedDecisionSetDigest, + receipt.reviewFileDigest, + receipt.reviewerDigest, + ].every( + (digest) => typeof digest === 'string' && DIGEST_PATTERN.test(digest), + ) || + ![receipt.rowCount, receipt.adoptedCount, receipt.skippedCount].every( + (count) => Number.isSafeInteger(count) && (count as number) >= 0, + ) || + (receipt.adoptedCount as number) + (receipt.skippedCount as number) !== + receipt.rowCount || + !Number.isSafeInteger(receipt.issuedAtMs) || + (receipt.issuedAtMs as number) < 0 || + !Number.isSafeInteger(receipt.expiresAtMs) || + (receipt.expiresAtMs as number) <= (receipt.issuedAtMs as number) || + typeof receipt.decisionId !== 'string' || + typeof receipt.automationId !== 'string' + ) { + configurationError('receipt binding is invalid'); + } + return Object.freeze(receipt) as unknown as Readonly; +} + +export function localReconciliationAutomationDecisionEvidenceContents( + value: + | Readonly + | Readonly, +): string { + return `${JSON.stringify(value, null, 2)}\n`; +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/planReader.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/planReader.ts new file mode 100644 index 00000000..da05824f --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/automation/planReader.ts @@ -0,0 +1,438 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; + +import type { ReconciliationAutomationDecisionRequirement } from '@qinglong/local-admin/reconciliation-automation-decision'; + +import { LocalDeploymentConfigurationError } from '../../../foundation/error'; +import { cutoverDigest } from '../../../cutover/targetEvidence'; +import type { + LocalReconciliationAutomationPlanHeader, + LocalReconciliationAutomationPlanReceipt, + LocalReconciliationAutomationPlanRow, +} from './rowPlan'; + +const HEADER_KIND = 'qinglong3-local-reconciliation-automation-plan-header'; +const ROW_KIND = 'qinglong3-local-reconciliation-automation-plan-row'; +const FOOTER_KIND = 'qinglong3-local-reconciliation-automation-plan-footer'; +const READ_CHUNK_BYTES = 64 * 1024; +const MAX_LINE_BYTES = 64 * 1024; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +interface FileLine { + readonly value: Buffer; + readonly framed: Buffer; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError( + `reconciliation automation plan reader ${message}`, + { cause }, + ); +} + +function exact( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + configurationError(`${label} must be an object`); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } + return record; +} + +function parse(line: Buffer, label: string): unknown { + if (line.length < 2 || line.length > MAX_LINE_BYTES) { + configurationError(`${label} exceeds its line bound`); + } + try { + return JSON.parse(line.toString('utf8')) as unknown; + } catch (error) { + return configurationError(`${label} is not JSON`, error); + } +} + +function* lines(descriptor: number, size: number): Iterable { + let position = 0; + let pending = Buffer.alloc(0); + try { + while (position < size) { + const chunk = Buffer.allocUnsafe( + Math.min(READ_CHUNK_BYTES, size - position), + ); + const bytesRead = fs.readSync( + descriptor, + chunk, + 0, + chunk.length, + position, + ); + if (bytesRead < 1) { + chunk.fill(0); + configurationError('file ended unexpectedly'); + } + position += bytesRead; + const material = pending.length + ? Buffer.concat([pending, chunk.subarray(0, bytesRead)]) + : Buffer.from(chunk.subarray(0, bytesRead)); + pending.fill(0); + chunk.fill(0); + let cursor = 0; + for (;;) { + const newline = material.indexOf(0x0a, cursor); + if (newline < 0) break; + const framed = Buffer.from(material.subarray(cursor, newline + 1)); + const value = Buffer.from(material.subarray(cursor, newline)); + cursor = newline + 1; + yield { value, framed }; + } + pending = Buffer.from(material.subarray(cursor)); + material.fill(0); + if (pending.length > MAX_LINE_BYTES) { + configurationError('record exceeds its line bound'); + } + } + if (pending.length !== 0) configurationError('file is not newline framed'); + } finally { + pending.fill(0); + } +} + +function header( + value: unknown, + receipt: Readonly, +): Readonly { + const record = exact( + value, + [ + 'applicationId', + 'applicationPlanDigest', + 'automationId', + 'bundleDigest', + 'bundleFingerprintDigest', + 'headerDigest', + 'kind', + 'legacyTimezone', + 'preparedAtMs', + 'preparedHeadDigest', + 'profile', + 'projectId', + 'reviewAuthorizationDigest', + 'reviewDecisionFileDigest', + 'reviewDecisionSetDigest', + 'reviewDigest', + 'schemaVersion', + 'tableDisposition', + ], + 'header', + ); + const { headerDigest, ...payload } = record; + if ( + record.schemaVersion !== 1 || + record.kind !== HEADER_KIND || + typeof headerDigest !== 'string' || + !DIGEST_PATTERN.test(headerDigest) || + cutoverDigest(payload) !== headerDigest || + record.automationId !== receipt.automationId || + record.applicationId !== receipt.applicationId || + record.applicationPlanDigest !== receipt.applicationPlanDigest || + record.preparedHeadDigest !== receipt.preparedHeadDigest || + record.preparedAtMs !== receipt.preparedAtMs || + (record.profile !== 'edge' && record.profile !== 'standalone') || + typeof record.projectId !== 'string' || + (record.legacyTimezone !== null && + typeof record.legacyTimezone !== 'string') || + (record.tableDisposition !== 'adopt_legacy' && + record.tableDisposition !== 'retain_both') || + ![ + record.reviewDigest, + record.reviewAuthorizationDigest, + record.reviewDecisionSetDigest, + record.reviewDecisionFileDigest, + record.bundleDigest, + record.bundleFingerprintDigest, + ].every( + (digest) => typeof digest === 'string' && DIGEST_PATTERN.test(digest), + ) + ) { + configurationError('header binding is invalid'); + } + return Object.freeze(record) as unknown as Readonly; +} + +function planRow( + value: unknown, + expectedOrdinal: number, +): Readonly { + const record = exact( + value, + [ + 'candidateDigest', + 'classification', + 'enabled', + 'kind', + 'proposedTaskId', + 'reasons', + 'requirement', + 'rowOrdinal', + 'rowPlanDigest', + 'schemaVersion', + 'sourceDigest', + 'target', + 'triggerCount', + ], + 'row', + ); + const { rowPlanDigest, ...payload } = record; + const target = record.target; + if ( + record.schemaVersion !== 1 || + record.kind !== ROW_KIND || + record.rowOrdinal !== expectedOrdinal || + typeof record.sourceDigest !== 'string' || + !DIGEST_PATTERN.test(record.sourceDigest) || + ![ + 'lossless', + 'requires_shell_compatibility', + 'requires_manual_action', + 'malformed', + ].includes(record.classification as string) || + !Array.isArray(record.reasons) || + record.reasons.some((reason) => typeof reason !== 'string') || + (record.proposedTaskId !== null && + typeof record.proposedTaskId !== 'string') || + (record.enabled !== null && typeof record.enabled !== 'boolean') || + !Number.isSafeInteger(record.triggerCount) || + (record.triggerCount as number) < 0 || + (record.candidateDigest !== null && + (typeof record.candidateDigest !== 'string' || + !DIGEST_PATTERN.test(record.candidateDigest))) || + !target || + typeof target !== 'object' || + Array.isArray(target) || + !['review_adopt', 'review_skip_conflict', 'manual_required'].includes( + record.requirement as string, + ) || + typeof rowPlanDigest !== 'string' || + !DIGEST_PATTERN.test(rowPlanDigest) || + cutoverDigest(payload) !== rowPlanDigest + ) { + configurationError('row binding is invalid'); + } + const targetRecord = target as Record; + if ( + (targetRecord.state === 'absent' && + Object.keys(targetRecord).sort().join('\0') !== 'state') || + (targetRecord.state === 'occupied' && + (Object.keys(targetRecord).sort().join('\0') !== + ['contentDigest', 'revision', 'state'].sort().join('\0') || + !Number.isSafeInteger(targetRecord.revision) || + (targetRecord.revision as number) < 1 || + typeof targetRecord.contentDigest !== 'string' || + !DIGEST_PATTERN.test(targetRecord.contentDigest))) || + (targetRecord.state !== 'absent' && targetRecord.state !== 'occupied') || + (record.requirement === 'review_adopt' && + targetRecord.state !== 'absent') || + (record.requirement === 'review_skip_conflict' && + targetRecord.state !== 'occupied') || + (record.requirement === 'manual_required' && + record.candidateDigest !== null) + ) { + configurationError('row target requirement is invalid'); + } + return Object.freeze(record) as unknown as Readonly; +} + +export function readLocalReconciliationAutomationPlanHeader( + filePath: string, + receipt: Readonly, + uid: number, +): Readonly { + let descriptor: number | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + (Number(before.mode) & 0o777) !== 0o400 || + before.nlink !== 1n || + before.size !== BigInt(receipt.planFileBytes) + ) { + configurationError('plan header file identity is invalid'); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size || + opened.mtimeNs !== before.mtimeNs || + opened.ctimeNs !== before.ctimeNs + ) { + configurationError('plan header file changed while opening'); + } + const records = lines(descriptor, Number(opened.size))[Symbol.iterator](); + const selected = records.next(); + if (selected.done) configurationError('plan header is missing'); + try { + return header(parse(selected.value.value, 'header'), receipt); + } finally { + selected.value.value.fill(0); + selected.value.framed.fill(0); + records.return?.(); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('plan header cannot be read', error); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +export function createLocalReconciliationAutomationRequirementFactory( + filePath: string, + receipt: Readonly, + uid: number, +): () => Iterable { + return () => + (function* (): Iterable { + let descriptor: number | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + if ( + !before.isFile() || + before.isSymbolicLink() || + Number(before.uid) !== uid || + (Number(before.mode) & 0o777) !== 0o400 || + before.nlink !== 1n || + before.size !== BigInt(receipt.planFileBytes) + ) { + configurationError('plan file identity is invalid'); + } + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size || + opened.mtimeNs !== before.mtimeNs || + opened.ctimeNs !== before.ctimeNs + ) { + configurationError('plan file changed while opening'); + } + const fileHash = createHash('sha256'); + const rowHash = createHash('sha256').update( + 'qinglong3.local-reconciliation-automation-row-set.v1\0', + ); + let parsedHeader: Readonly | undefined; + let rowCount = 0; + let footerSeen = false; + for (const selected of lines(descriptor, Number(opened.size))) { + try { + fileHash.update(selected.framed); + const value = parse(selected.value, 'record'); + if (!parsedHeader) { + parsedHeader = header(value, receipt); + continue; + } + const kind = (value as { readonly kind?: unknown })?.kind; + if (kind === ROW_KIND && !footerSeen) { + const row = planRow(value, rowCount + 1); + rowHash.update(selected.framed); + rowCount += 1; + yield Object.freeze({ + rowOrdinal: row.rowOrdinal, + sourceDigest: row.sourceDigest, + classification: row.classification, + requirement: row.requirement, + }); + continue; + } + const footer = exact( + value, + [ + 'automationId', + 'automationPlanDigest', + 'conflictCount', + 'eligibleCount', + 'kind', + 'legacyInventoryDigest', + 'manualCount', + 'outcome', + 'rowCount', + 'rowSetDigest', + 'schemaVersion', + 'shellCompatibilityCount', + 'triggerCount', + ], + 'footer', + ); + const { automationPlanDigest, ...footerPayload } = footer; + if ( + footerSeen || + footer.schemaVersion !== 1 || + footer.kind !== FOOTER_KIND || + footer.automationId !== receipt.automationId || + footer.rowCount !== rowCount || + footer.rowCount !== receipt.rowCount || + footer.legacyInventoryDigest !== receipt.legacyInventoryDigest || + footer.rowSetDigest !== rowHash.digest('hex') || + footer.eligibleCount !== receipt.eligibleCount || + footer.manualCount !== receipt.manualCount || + footer.conflictCount !== receipt.conflictCount || + footer.shellCompatibilityCount !== + receipt.shellCompatibilityCount || + footer.triggerCount !== receipt.triggerCount || + footer.outcome !== receipt.outcome || + automationPlanDigest !== receipt.automationPlanDigest || + cutoverDigest({ + headerDigest: parsedHeader.headerDigest, + ...footerPayload, + }) !== automationPlanDigest + ) { + configurationError('footer binding is invalid'); + } + footerSeen = true; + } finally { + selected.value.fill(0); + selected.framed.fill(0); + } + } + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + !parsedHeader || + !footerSeen || + rowCount !== receipt.rowCount || + fileHash.digest('hex') !== receipt.planFileDigest || + after.dev !== opened.dev || + after.ino !== opened.ino || + after.size !== opened.size || + after.mtimeNs !== opened.mtimeNs || + after.ctimeNs !== opened.ctimeNs + ) { + configurationError('plan file content drifted'); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError('plan file cannot be read', error); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + })(); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/sealed-bundle/reader.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/sealed-bundle/reader.ts index ddc242ea..ae526ade 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/sealed-bundle/reader.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/sealed-bundle/reader.ts @@ -337,3 +337,64 @@ export function withLocalReconciliationSealedDatabase( } return output; } + +export async function withLocalReconciliationSealedDatabaseAsync( + bundle: Readonly, + kind: LocalReconciliationSealedDatabaseKind, + uid: number, + dependencies: LocalReconciliationSealedBundleReaderDependencies, + read: (client: DatabaseSync) => Promise, +): Promise { + const current = inspectLocalReconciliationSealedBundle( + bundle.captureRoot, + bundle.receipt.captureId, + uid, + ); + if ( + current.receipt.bundleDigest !== bundle.receipt.bundleDigest || + current.fingerprintDigest !== bundle.fingerprintDigest + ) { + configurationError('sealed capture bundle drifted before SQLite open'); + } + const selected = kind === 'target' ? current.target : current.legacy; + if (selected.mode === 'manual_required') return null; + const cacheKiB = current.manifest.profile === 'edge' ? 2_048 : 8_192; + dependencies.beforeDatabaseOpen?.(kind, selected.mode, cacheKiB); + const mainPath = databasePath(current, kind); + const source = + selected.mode === 'main_only_immutable' + ? `file:${mainPath}?immutable=1` + : mainPath; + let client: DatabaseSync | undefined; + let output: T; + try { + client = new DatabaseSync(source, { + allowExtension: false, + defensive: true, + enableDoubleQuotedStringLiterals: false, + enableForeignKeyConstraints: true, + readOnly: true, + timeout: 0, + }); + configureReadOnlyDatabase(client, current.manifest.profile); + output = await read(client); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('sealed SQLite inventory failed', error); + } finally { + if (client !== undefined) client.close(); + dependencies.afterDatabaseClose?.(kind); + } + const after = inspectLocalReconciliationSealedBundle( + bundle.captureRoot, + bundle.receipt.captureId, + uid, + ); + if ( + after.receipt.bundleDigest !== current.receipt.bundleDigest || + after.fingerprintDigest !== current.fingerprintDigest + ) { + configurationError('sealed capture bundle drifted after SQLite close'); + } + return output; +} diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts index 45c7fc4a..ee3dfa2a 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts @@ -756,7 +756,9 @@ function replayResult( head.state === 'reconciliation_reviewed' || head.state === 'reconciliation_application_prepared' || head.state === 'reconciliation_application_planned' || - head.state === 'reconciliation_automation_planned'); + head.state === 'reconciliation_automation_planned' || + head.state === 'reconciliation_automation_decision_prepared' || + head.state === 'reconciliation_automation_reviewed'); if ( record.actionId !== intent.actionId || record.intentDigest !== intent.intentDigest || diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 6384492b..6066dc46 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -10,15 +10,18 @@ const { test } = require('node:test'); const { commitLocalReconciliationCapture, commitLocalReconciliationApplication, + commitLocalReconciliationAutomationDecision, commitLocalReconciliationPlan, commitLocalReconciliationReview, prepareLocalReconciliationCapture, prepareLocalReconciliationApplication, + prepareLocalReconciliationAutomationDecision, planLocalReconciliationAutomation, prepareLocalReconciliationPlan, prepareLocalReconciliationReview, verifyLocalReconciliationCapture, verifyLocalReconciliationApplication, + verifyLocalReconciliationAutomationDecision, verifyLocalReconciliationAutomationPlan, verifyLocalReconciliationPlan, verifyLocalReconciliationReview, @@ -1069,6 +1072,236 @@ function applicationCommitCommand(state, prepared) { }; } +async function plannedAutomationFixture(t, options = {}) { + const suffix = options.suffix ?? 'decision'; + const state = await reviewedApplicationFixture(t, { + planId: options.planId, + reviewId: options.reviewId, + applicationId: options.applicationId, + reviewSuffix: `automation-decision-${suffix}`, + createDefaultSidecars: false, + initializeDatabases: automationDatabaseInitializer(), + mutateTarget(paths) { + return mutateAutomationTarget(paths, options.occupied === true); + }, + mutateDecisions(records) { + const selected = records.find( + (record) => + record.kind === 'qinglong3-local-reconciliation-review-decision' && + record.database === 'legacy' && + record.domain === 'automation' && + record.factKind === 'table' && + 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'; + }, + }); + const preparedApplication = await prepareLocalReconciliationApplication( + state.prepareApplicationCommand, + ); + const application = await commitLocalReconciliationApplication( + applicationCommitCommand(state, preparedApplication), + ); + const automationRoot = path.join( + path.dirname(state.captureRoot), + `automation-decision-plan-${suffix}`, + ); + const automationDecisionRoot = path.join( + path.dirname(state.captureRoot), + `automation-decision-authority-${suffix}`, + ); + fs.mkdirSync(automationRoot, { mode: 0o700 }); + fs.mkdirSync(automationDecisionRoot, { mode: 0o700 }); + const automationId = + options.automationId ?? '00000000-0000-4000-8000-000000000461'; + const automationCommand = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.plan', + options: { + deploymentRoot: state.deploymentRoot, + applicationRoot: state.applicationRoot, + automationRoot, + allowRootService: rootAcknowledgement(), + }, + request: { + automationId, + applicationId: state.prepareApplicationCommand.request.applicationId, + expectedApplicationPlanDigest: application.applicationPlanDigest, + expectedHeadDigest: application.instanceHeadDigest, + decisionFilePath: state.reviewFile.filePath, + projectId: 'default', + legacyTimezone: 'Asia/Shanghai', + preparedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 2, + }, + }; + const planned = await planLocalReconciliationAutomation(automationCommand); + const automationDirectory = path.join(automationRoot, automationId); + const planReceipt = JSON.parse( + fs.readFileSync(path.join(automationDirectory, 'receipt.json'), 'utf8'), + ); + const planRows = fs + .readFileSync(path.join(automationDirectory, 'plan.ndjson'), 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)) + .filter( + (record) => + record.kind === 'qinglong3-local-reconciliation-automation-plan-row', + ); + return { + ...state, + application, + automationRoot, + automationDecisionRoot, + automationCommand, + automationDirectory, + planned, + planReceipt, + planRows, + }; +} + +function automationDecisionReviewFile( + state, + decisionId, + disposition, + reason, + suffix = 'decision', +) { + assert.equal(state.planRows.length, 1); + const row = state.planRows[0]; + const records = [ + { + schemaVersion: 1, + kind: 'qinglong3-legacy-crontab-decision-review-file-header', + decisionId, + profile: state.captureCommand.request.profile, + planDigest: state.planned.automationPlanDigest, + inventoryDigest: state.planReceipt.legacyInventoryDigest, + }, + { + schemaVersion: 1, + kind: 'qinglong3-legacy-crontab-decision-review-file-row', + decision: { + rowOrdinal: row.rowOrdinal, + sourceDigest: row.sourceDigest, + disposition, + reason, + }, + }, + ]; + const filePath = path.join( + state.diagnosticRoot, + `automation-row-decision-${suffix}.ndjson`, + ); + fs.writeFileSync( + filePath, + `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + { mode: 0o600 }, + ); + return { filePath, records }; +} + +function automationDecisionPrepareCommand(state, decisionId) { + return { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.prepare', + options: { + deploymentRoot: state.deploymentRoot, + applicationRoot: state.applicationRoot, + automationRoot: state.automationRoot, + automationDecisionRoot: state.automationDecisionRoot, + allowRootService: rootAcknowledgement(), + }, + request: { + decisionId, + automationId: state.automationCommand.request.automationId, + expectedAutomationPlanDigest: state.planned.automationPlanDigest, + expectedHeadDigest: state.planned.instanceHeadDigest, + preparedAtMs: state.automationCommand.request.preparedAtMs + 1, + }, + }; +} + +function automationDecisionCommitFixture( + state, + prepared, + decisionFilePath, + options = {}, +) { + const committedAtMs = options.committedAtMs ?? Date.now(); + const authorizationLifetimeMs = 10 * 60 * 1_000; + let authentications = 0; + let confirmations = 0; + let databaseCloses = 0; + const command = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.commit', + options: { + ...prepared.commandOptions, + targetDatabasePath: state.targetDatabasePath, + ownerPepperKeyringDirectory: state.command.options.ownerPepperKeyringDirectory, + credentialFilePath: state.command.options.credentialFilePath, + }, + request: { + decisionId: prepared.result.decisionId, + automationId: prepared.result.automationId, + expectedPreparationDigest: prepared.result.preparationDigest, + expectedHeadDigest: prepared.result.instanceHeadDigest, + decisionFilePath, + committedAtMs, + authorizationLifetimeMs, + }, + }; + const dependencies = { + now: () => committedAtMs, + async openAuthenticationDatabase() { + return { + async close() { + databaseCloses += 1; + }, + }; + }, + async authenticate(_database, authenticateOptions) { + authentications += 1; + assert.equal( + authenticateOptions.authenticationNamespace, + 'local_reconciliation_automation', + ); + return { + principal: { + subject: { + type: 'user', + id: options.reviewerId ?? 'review-owner', + }, + authenticationId: 'local_reconciliation_automation:test', + authenticatedAtMs: committedAtMs, + expiresAtMs: committedAtMs + authorizationLifetimeMs + 60_000, + assurance: options.assurance ?? 'local_console', + }, + databaseFence: { + credentialId: options.reviewerId ?? 'review-owner', + credentialVersion: 1, + pepperKeyId: 'review-owner-v1', + pepperVersion: 1, + }, + async confirm() { + confirmations += 1; + }, + }; + }, + }; + return { + command, + dependencies, + authenticationCount: () => authentications, + confirmationCount: () => confirmations, + databaseCloseCount: () => databaseCloses, + }; +} + function dockerReadSealedSqlite(assetsDirectory, mode) { const source = mode === 'main_only_immutable' @@ -2754,6 +2987,429 @@ test('automation adapter builds a sealed row plan with bounded conflict evidence assert.equal(cli.stdout.includes('legacy-cron:1'), false); }); +test('automation decision reauthenticates the same reviewer, seals exact row decisions and verifies content-free', async (t) => { + const state = await plannedAutomationFixture(t, { + suffix: 'signed-success', + planId: '00000000-0000-4000-8000-000000000461', + reviewId: '00000000-0000-4000-8000-000000000462', + applicationId: '00000000-0000-4000-8000-000000000463', + automationId: '00000000-0000-4000-8000-000000000464', + }); + assert.equal(state.planRows[0].requirement, 'review_adopt'); + const decisionId = '019b0000-0000-7000-8000-000000000461'; + const review = automationDecisionReviewFile( + state, + decisionId, + 'adopt', + 'reviewed_lossless', + 'signed-success', + ); + const prepareCommand = automationDecisionPrepareCommand(state, decisionId); + const prepared = await prepareLocalReconciliationAutomationDecision( + prepareCommand, + ); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.state, 'reconciliation_automation_decision_prepared'); + const commit = automationDecisionCommitFixture( + state, + { result: prepared, commandOptions: prepareCommand.options }, + review.filePath, + ); + const targetBytes = fs.readFileSync(state.targetDatabasePath); + const committed = await commitLocalReconciliationAutomationDecision( + commit.command, + commit.dependencies, + ); + assert.equal(committed.status, 'prepared'); + assert.equal(committed.state, 'reconciliation_automation_reviewed'); + assert.equal(committed.rowCount, 1); + assert.equal(committed.adoptedCount, 1); + assert.equal(committed.skippedCount, 0); + assert.equal(commit.authenticationCount(), 1); + assert.equal(commit.confirmationCount(), 1); + assert.equal(commit.databaseCloseCount(), 1); + assert.equal( + fs.readFileSync(state.targetDatabasePath).equals(targetBytes), + true, + ); + const decisionDirectory = path.join( + state.automationDecisionRoot, + state.automationCommand.request.automationId, + ); + assert.deepEqual(fs.readdirSync(decisionDirectory).sort(), [ + 'authorization.ndjson', + 'intent.json', + 'receipt.json', + 'staging', + ]); + assert.equal(fs.statSync(decisionDirectory).mode & 0o777, 0o500); + assert.equal( + fs.statSync(path.join(decisionDirectory, 'staging')).mode & 0o777, + 0o500, + ); + for (const name of ['authorization.ndjson', 'intent.json', 'receipt.json']) { + assert.equal( + fs.statSync(path.join(decisionDirectory, name)).mode & 0o777, + 0o400, + ); + } + const verifyCommand = { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.verify', + options: prepareCommand.options, + request: { + decisionId, + automationId: state.automationCommand.request.automationId, + expectedDecisionDigest: committed.decisionDigest, + }, + }; + const verified = await verifyLocalReconciliationAutomationDecision( + verifyCommand, + ); + assert.equal(verified.status, 'verified'); + 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 commandPath = path.join( + state.deploymentRoot, + 'automation-decision-verify.json', + ); + fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, { + mode: 0o600, + }); + const cli = spawnSync( + process.execPath, + [ + path.join(__dirname, '../dist/deployment/localDeploymentCli.js'), + 'reconciliation-automation-decision-verify', + '--command-file', + commandPath, + ], + { encoding: 'utf8' }, + ); + assert.equal(cli.status, 0, cli.stderr); + assert.equal(JSON.parse(cli.stdout).status, 'verified'); + assert.equal(cli.stdout.includes('review-owner'), false); + assert.equal(cli.stdout.includes(state.planRows[0].sourceDigest), false); +}); + +test('automation decision rejects conflict adoption, another reviewer and weak assurance', async (t) => { + const conflict = await plannedAutomationFixture(t, { + suffix: 'conflict-reject', + occupied: true, + planId: '00000000-0000-4000-8000-000000000465', + reviewId: '00000000-0000-4000-8000-000000000466', + applicationId: '00000000-0000-4000-8000-000000000467', + automationId: '00000000-0000-4000-8000-000000000468', + }); + assert.equal(conflict.planRows[0].requirement, 'review_skip_conflict'); + const conflictDecisionId = '019b0000-0000-7000-8000-000000000465'; + const conflictReview = automationDecisionReviewFile( + conflict, + conflictDecisionId, + 'adopt', + 'reviewed_lossless', + 'conflict-reject', + ); + const conflictPrepareCommand = automationDecisionPrepareCommand( + conflict, + conflictDecisionId, + ); + const conflictPrepared = await prepareLocalReconciliationAutomationDecision( + conflictPrepareCommand, + ); + const conflictCommit = automationDecisionCommitFixture( + conflict, + { + result: conflictPrepared, + commandOptions: conflictPrepareCommand.options, + }, + conflictReview.filePath, + ); + await assert.rejects( + commitLocalReconciliationAutomationDecision( + conflictCommit.command, + conflictCommit.dependencies, + ), + (error) => { + const messages = []; + for (let current = error; current; current = current.cause) { + messages.push(String(current.message)); + } + assert.match( + messages.join('\n'), + /conflict or manual row cannot be adopted/, + ); + return true; + }, + ); + assert.equal( + fs.existsSync( + path.join( + conflict.automationDecisionRoot, + conflict.automationCommand.request.automationId, + 'authorization.ndjson', + ), + ), + false, + ); + + const identity = await plannedAutomationFixture(t, { + suffix: 'identity-reject', + planId: '00000000-0000-4000-8000-000000000469', + reviewId: '00000000-0000-4000-8000-00000000046a', + applicationId: '00000000-0000-4000-8000-00000000046b', + automationId: '00000000-0000-4000-8000-00000000046c', + }); + const identityDecisionId = '019b0000-0000-7000-8000-000000000469'; + const identityReview = automationDecisionReviewFile( + identity, + identityDecisionId, + 'adopt', + 'reviewed_lossless', + 'identity-reject', + ); + const identityPrepareCommand = automationDecisionPrepareCommand( + identity, + identityDecisionId, + ); + const identityPrepared = await prepareLocalReconciliationAutomationDecision( + identityPrepareCommand, + ); + for (const auth of [ + { reviewerId: 'another-owner', assurance: 'local_console' }, + { reviewerId: 'review-owner', assurance: 'password' }, + ]) { + const rejected = automationDecisionCommitFixture( + identity, + { + result: identityPrepared, + commandOptions: identityPrepareCommand.options, + }, + identityReview.filePath, + auth, + ); + await assert.rejects( + commitLocalReconciliationAutomationDecision( + rejected.command, + rejected.dependencies, + ), + /requires the same recently strong authenticated User/, + ); + assert.equal(rejected.authenticationCount(), 1); + assert.equal(rejected.confirmationCount(), 0); + assert.equal(rejected.databaseCloseCount(), 1); + } +}); + +test('automation decision replays every publication boundary without repeated authentication', async (t) => { + const prepareState = await plannedAutomationFixture(t, { + suffix: 'prepare-response-loss', + automationId: '00000000-0000-4000-8000-00000000046d', + }); + const prepareDecisionId = '019b0000-0000-7000-8000-00000000046d'; + const prepareCommand = automationDecisionPrepareCommand( + prepareState, + prepareDecisionId, + ); + await assert.rejects( + prepareLocalReconciliationAutomationDecision(prepareCommand, { + afterHeadPrepared() { + throw new Error('automation decision prepare response loss'); + }, + }), + /automation decision prepare response loss/, + ); + const prepareReplay = await prepareLocalReconciliationAutomationDecision( + prepareCommand, + ); + assert.equal( + prepareReplay.state, + 'reconciliation_automation_decision_prepared', + ); + + for (const [window, tail] of [ + ['authorization', '471'], + ['receipt', '472'], + ['seal', '473'], + ['head', '474'], + ]) { + const state = await plannedAutomationFixture(t, { + suffix: `${window}-response-loss`, + automationId: `00000000-0000-4000-8000-000000000${tail}`, + }); + const decisionId = `019b0000-0000-7000-8000-000000000${tail}`; + const review = automationDecisionReviewFile( + state, + decisionId, + 'adopt', + 'reviewed_lossless', + `${window}-response-loss`, + ); + const selectedPrepareCommand = automationDecisionPrepareCommand( + state, + decisionId, + ); + const prepared = await prepareLocalReconciliationAutomationDecision( + selectedPrepareCommand, + ); + const commit = automationDecisionCommitFixture( + state, + { result: prepared, commandOptions: selectedPrepareCommand.options }, + review.filePath, + ); + const callback = + window === 'authorization' + ? 'afterAuthorizationPublished' + : window === 'receipt' + ? 'afterReceiptPublished' + : window === 'seal' + ? 'afterTerminalSealed' + : 'afterHeadAdvanced'; + await assert.rejects( + commitLocalReconciliationAutomationDecision(commit.command, { + ...commit.dependencies, + [callback]() { + throw new Error(`automation decision ${window} response loss`); + }, + }), + new RegExp(`automation decision ${window} response loss`), + ); + const replay = await commitLocalReconciliationAutomationDecision( + commit.command, + commit.dependencies, + ); + assert.equal(replay.state, 'reconciliation_automation_reviewed'); + assert.equal(commit.authenticationCount(), 1); + assert.equal(commit.confirmationCount(), 1); + assert.equal(commit.databaseCloseCount(), 1); + if (window === 'head') assert.equal(replay.status, 'existing'); + } +}); + +test('automation decision verification rejects sealed authorization and plan drift', async (t) => { + const authorizationState = await plannedAutomationFixture(t, { + suffix: 'authorization-drift', + automationId: '00000000-0000-4000-8000-000000000475', + }); + const authorizationDecisionId = + '019b0000-0000-7000-8000-000000000475'; + const authorizationReview = automationDecisionReviewFile( + authorizationState, + authorizationDecisionId, + 'adopt', + 'reviewed_lossless', + 'authorization-drift', + ); + const authorizationPrepareCommand = automationDecisionPrepareCommand( + authorizationState, + authorizationDecisionId, + ); + const authorizationPrepared = + await prepareLocalReconciliationAutomationDecision( + authorizationPrepareCommand, + ); + const authorizationCommit = automationDecisionCommitFixture( + authorizationState, + { + result: authorizationPrepared, + commandOptions: authorizationPrepareCommand.options, + }, + authorizationReview.filePath, + ); + const committed = await commitLocalReconciliationAutomationDecision( + authorizationCommit.command, + authorizationCommit.dependencies, + ); + const decisionDirectory = path.join( + authorizationState.automationDecisionRoot, + authorizationState.automationCommand.request.automationId, + ); + const authorizationPath = path.join( + decisionDirectory, + 'authorization.ndjson', + ); + fs.chmodSync(decisionDirectory, 0o700); + fs.chmodSync(authorizationPath, 0o600); + fs.appendFileSync(authorizationPath, '{}\n'); + fs.chmodSync(authorizationPath, 0o400); + fs.chmodSync(decisionDirectory, 0o500); + await assert.rejects( + verifyLocalReconciliationAutomationDecision({ + schemaVersion: 1, + operation: 'local.deployment.reconciliation.automation.decision.verify', + options: authorizationPrepareCommand.options, + request: { + decisionId: authorizationDecisionId, + automationId: authorizationState.automationCommand.request.automationId, + expectedDecisionDigest: committed.decisionDigest, + }, + }), + (error) => { + const messages = []; + for (let current = error; current; current = current.cause) { + messages.push(String(current.message)); + } + assert.match( + messages.join('\n'), + /authorization verification failed|authorization file/, + ); + return true; + }, + ); + + const planState = await plannedAutomationFixture(t, { + suffix: 'plan-drift-decision', + automationId: '00000000-0000-4000-8000-000000000476', + }); + const planDecisionId = '019b0000-0000-7000-8000-000000000476'; + const planReview = automationDecisionReviewFile( + planState, + planDecisionId, + 'adopt', + 'reviewed_lossless', + 'plan-drift-decision', + ); + const planPrepareCommand = automationDecisionPrepareCommand( + planState, + planDecisionId, + ); + const planPrepared = await prepareLocalReconciliationAutomationDecision( + planPrepareCommand, + ); + const planPath = path.join(planState.automationDirectory, 'plan.ndjson'); + fs.chmodSync(planState.automationDirectory, 0o700); + fs.chmodSync(planPath, 0o600); + fs.appendFileSync(planPath, '{}\n'); + fs.chmodSync(planPath, 0o400); + fs.chmodSync(planState.automationDirectory, 0o500); + const planCommit = automationDecisionCommitFixture( + planState, + { result: planPrepared, commandOptions: planPrepareCommand.options }, + planReview.filePath, + ); + await assert.rejects( + commitLocalReconciliationAutomationDecision( + planCommit.command, + planCommit.dependencies, + ), + (error) => { + const messages = []; + for (let current = error; current; current = current.cause) { + messages.push(String(current.message)); + } + assert.match( + messages.join('\n'), + /plan file identity is invalid|plan file content drifted/, + ); + return true; + }, + ); + assert.equal(planCommit.authenticationCount(), 0); +}); + test('automation row plan fails closed to manual review on a target task collision', async (t) => { const state = await reviewedApplicationFixture(t, { planId: '00000000-0000-4000-8000-000000000435', diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 30478607..34930de8 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -2018,6 +2018,21 @@ function auditSourceImports(root, packagePath, findings) { '@qinglong/local-sqlite/authentication-read', ].includes(specifier) ) && + !( + path.relative(packageDirectory, filePath) === + 'src/deployment/reconciliation/application/automation/decisionCoordinator.ts' && + [ + '@qinglong/local-admin/reconciliation-automation-decision', + '@qinglong/local-owner-console/authenticated-command', + '@qinglong/local-sqlite/authentication-read', + ].includes(specifier) + ) && + !( + path.relative(packageDirectory, filePath) === + 'src/deployment/reconciliation/application/automation/planReader.ts' && + specifier === + '@qinglong/local-admin/reconciliation-automation-decision' + ) && !( path.relative(packageDirectory, filePath) === 'src/deployment/reconciliation/application/automation/rowPlan.ts' && @@ -2195,6 +2210,12 @@ function auditSourceImports(root, packagePath, findings) { (path.relative(packageDirectory, filePath) === 'src/legacy-adoption/legacyCrontabDecisionIssuerKeyring.ts' && specifier === '@qinglong/runtime-core/local-secret') || + (path.relative(packageDirectory, filePath) === + 'src/legacy-adoption/reconciliationAutomationDecision.ts' && + [ + '@qinglong/runtime-core/local-secret', + '@qinglong/runtime-core/security', + ].includes(specifier)) || (path.relative(packageDirectory, filePath) === 'src/plugin-package/pluginPackageStaging.ts' && [