feat(local): authorize reconciliation automation decisions

This commit is contained in:
whyour
2026-08-22 18:01:24 +08:00
parent 35efa1f33e
commit e6615c7088
15 changed files with 3444 additions and 8 deletions
+5
View File
@@ -45,6 +45,11 @@
"require": "./dist/legacy-adoption/legacyCrontabInspection.js", "require": "./dist/legacy-adoption/legacyCrontabInspection.js",
"default": "./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": { "./package-staging": {
"types": "./dist/plugin-package/pluginPackageStaging.d.ts", "types": "./dist/plugin-package/pluginPackageStaging.d.ts",
"require": "./dist/plugin-package/pluginPackageStaging.js", "require": "./dist/plugin-package/pluginPackageStaging.js",
@@ -88,6 +88,8 @@ export interface VerifyLegacyCrontabDecisionAuthorizationFileOptions {
readonly expectedPlanDigest: string; readonly expectedPlanDigest: string;
readonly expectedInventoryDigest: string; readonly expectedInventoryDigest: string;
readonly keyProvider: LocalSecretKeyProvider; readonly keyProvider: LocalSecretKeyProvider;
readonly allowedModes?: readonly (0o400 | 0o600)[];
readonly allowedParentModes?: readonly (0o500 | 0o700)[];
readonly verifyReceipt: ( readonly verifyReceipt: (
receipt: unknown, receipt: unknown,
decisions: Iterable<LegacyCrontabAdoptionDecision>, decisions: Iterable<LegacyCrontabAdoptionDecision>,
@@ -218,7 +220,47 @@ function currentUid(): number {
return uid; 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; let stat: fs.Stats;
try { try {
stat = fs.lstatSync(path.dirname(filePath)); stat = fs.lstatSync(path.dirname(filePath));
@@ -232,7 +274,7 @@ function assertPrivateParent(filePath: string, uid: number): void {
!stat.isDirectory() || !stat.isDirectory() ||
stat.isSymbolicLink() || stat.isSymbolicLink() ||
stat.uid !== uid || stat.uid !== uid ||
(stat.mode & 0o777) !== 0o700 !allowedModes.includes(stat.mode & 0o777)
) { ) {
throw new LegacyCrontabDecisionAuthorizationFileError( throw new LegacyCrontabDecisionAuthorizationFileError(
'parent must be an owner-only real directory', 'parent must be an owner-only real directory',
@@ -693,7 +735,7 @@ export async function publishLegacyCrontabDecisionAuthorizationFile(
): Promise<LegacyCrontabDecisionAuthorizationFileResult> { ): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
const filePath = authorizationPath(options.filePath); const filePath = authorizationPath(options.filePath);
const uid = currentUid(); const uid = currentUid();
assertPrivateParent(filePath, uid); assertPrivateParent(filePath, uid, [0o700]);
const fileHeader = header(options); const fileHeader = header(options);
const temporary = path.join( const temporary = path.join(
path.dirname(filePath), path.dirname(filePath),
@@ -860,7 +902,9 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
): Promise<LegacyCrontabDecisionAuthorizationFileResult | T> { ): Promise<LegacyCrontabDecisionAuthorizationFileResult | T> {
const filePath = authorizationPath(options.filePath); const filePath = authorizationPath(options.filePath);
const uid = currentUid(); const uid = currentUid();
assertPrivateParent(filePath, uid); const allowedModes = verificationModes(options.allowedModes);
const allowedParentModes = verificationParentModes(options.allowedParentModes);
assertPrivateParent(filePath, uid, allowedParentModes);
assertHeaderIdentity({ assertHeaderIdentity({
decisionId: options.expectedDecisionId, decisionId: options.expectedDecisionId,
profile: options.expectedProfile, profile: options.expectedProfile,
@@ -874,7 +918,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
!before.isFile() || !before.isFile() ||
before.isSymbolicLink() || before.isSymbolicLink() ||
Number(before.uid) !== uid || Number(before.uid) !== uid ||
(Number(before.mode) & 0o777) !== 0o600 || !allowedModes.includes(Number(before.mode) & 0o777) ||
before.size < 1n || before.size < 1n ||
before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_AUTHORIZATION_FILE_BYTES) before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_AUTHORIZATION_FILE_BYTES)
) { ) {
@@ -1032,7 +1076,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
afterPath.mtimeNs !== opened.mtimeNs || afterPath.mtimeNs !== opened.mtimeNs ||
afterPath.ctimeNs !== opened.ctimeNs || afterPath.ctimeNs !== opened.ctimeNs ||
Number(afterPath.uid) !== uid || Number(afterPath.uid) !== uid ||
(Number(afterPath.mode) & 0o777) !== 0o600 !allowedModes.includes(Number(afterPath.mode) & 0o777)
) { ) {
throw new LegacyCrontabDecisionAuthorizationFileError( throw new LegacyCrontabDecisionAuthorizationFileError(
'file identity changed during verification', 'file identity changed during verification',
@@ -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<ReconciliationAutomationDecisionRequirement>;
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<void>;
}
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<LegacyCrontabAdoptionDecision>;
}
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<ReconciliationAutomationDecisionRequirement> {
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<T>(value: Iterable<T>, label: string): Iterator<T> {
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<LegacyCrontabAdoptionDecision>,
openRequirements: () => Iterable<ReconciliationAutomationDecisionRequirement>,
): Iterable<LegacyCrontabAdoptionDecision> {
if (typeof openRequirements !== 'function') {
throw new ReconciliationAutomationDecisionError(
'plan requirement factory is invalid',
);
}
return (function* (): Iterable<LegacyCrontabAdoptionDecision> {
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<ReconciliationAutomationDecisionRequirement>,
) {
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<LegacyCrontabAdoptionDecision>,
) =>
verifyLegacyCrontabAdoptionDecisionReceipt(
options.sourceClient,
options.timezone,
receipt,
decisionsBoundToPlan(decisions, openRequirements),
options.observedAtMs,
),
} as const;
}
export async function issueReconciliationAutomationDecision(
options: IssueReconciliationAutomationDecisionOptions,
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
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<Readonly<ReconciliationAutomationDecisionPublication>> {
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<T>(
options: ReconciliationAutomationDecisionVerificationOptions,
consumer: (
scope: VerifiedReconciliationAutomationDecisionScope,
) => T | Promise<T>,
): Promise<T> {
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<LegacyCrontabDecisionAuthorizationFileResult> {
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;
});
}
@@ -32,6 +32,8 @@ export type LocalCutoverInstanceHeadState =
| 'reconciliation_application_prepared' | 'reconciliation_application_prepared'
| 'reconciliation_application_planned' | 'reconciliation_application_planned'
| 'reconciliation_automation_planned' | 'reconciliation_automation_planned'
| 'reconciliation_automation_decision_prepared'
| 'reconciliation_automation_reviewed'
| 'rollback_prepared' | 'rollback_prepared'
| 'legacy_restart_requested' | 'legacy_restart_requested'
| 'legacy_running' | 'legacy_running'
@@ -172,6 +174,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
head.state !== 'reconciliation_application_prepared' && head.state !== 'reconciliation_application_prepared' &&
head.state !== 'reconciliation_application_planned' && 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' &&
head.state !== 'rollback_prepared' && head.state !== 'rollback_prepared' &&
head.state !== 'legacy_restart_requested' && head.state !== 'legacy_restart_requested' &&
head.state !== 'legacy_running' && head.state !== 'legacy_running' &&
@@ -347,6 +351,8 @@ export function advanceLocalCutoverInstanceHead(
| 'reconciliation_application_prepared' | 'reconciliation_application_prepared'
| 'reconciliation_application_planned' | 'reconciliation_application_planned'
| 'reconciliation_automation_planned' | 'reconciliation_automation_planned'
| 'reconciliation_automation_decision_prepared'
| 'reconciliation_automation_reviewed'
| 'rollback_prepared' | 'rollback_prepared'
| 'legacy_restart_requested' | 'legacy_restart_requested'
| 'legacy_running' | 'legacy_running'
@@ -394,6 +400,8 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_application_prepared' || current.state === 'reconciliation_application_prepared' ||
current.state === 'reconciliation_application_planned' || current.state === 'reconciliation_application_planned' ||
current.state === 'reconciliation_automation_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_restart_requested' ||
current.state === 'legacy_running' || current.state === 'legacy_running' ||
current.state === 'legacy_ready') current.state === 'legacy_ready')
@@ -427,6 +435,10 @@ export function advanceLocalCutoverInstanceHead(
current.state === 'reconciliation_application_prepared') || current.state === 'reconciliation_application_prepared') ||
(state === 'reconciliation_automation_planned' && (state === 'reconciliation_automation_planned' &&
current.state === 'reconciliation_application_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 === 'rollback_prepared' && current.state === 'target_stopped') ||
(state === 'legacy_restart_requested' && (state === 'legacy_restart_requested' &&
current.state === 'rollback_prepared') || current.state === 'rollback_prepared') ||
@@ -137,6 +137,14 @@ import {
verifyLocalReconciliationAutomationPlan, verifyLocalReconciliationAutomationPlan,
verifyLocalReconciliationAutomationPlanCommandFile, verifyLocalReconciliationAutomationPlanCommandFile,
} from './reconciliation/application/automation/coordinator'; } from './reconciliation/application/automation/coordinator';
import {
commitLocalReconciliationAutomationDecision,
commitLocalReconciliationAutomationDecisionCommandFile,
prepareLocalReconciliationAutomationDecision,
prepareLocalReconciliationAutomationDecisionCommandFile,
verifyLocalReconciliationAutomationDecision,
verifyLocalReconciliationAutomationDecisionCommandFile,
} from './reconciliation/application/automation/decisionCoordinator';
export { export {
commitLocalReconciliationPlan, commitLocalReconciliationPlan,
@@ -163,6 +171,12 @@ export {
planLocalReconciliationAutomationCommandFile, planLocalReconciliationAutomationCommandFile,
verifyLocalReconciliationAutomationPlan, verifyLocalReconciliationAutomationPlan,
verifyLocalReconciliationAutomationPlanCommandFile, verifyLocalReconciliationAutomationPlanCommandFile,
prepareLocalReconciliationAutomationDecision,
prepareLocalReconciliationAutomationDecisionCommandFile,
commitLocalReconciliationAutomationDecision,
commitLocalReconciliationAutomationDecisionCommandFile,
verifyLocalReconciliationAutomationDecision,
verifyLocalReconciliationAutomationDecisionCommandFile,
}; };
export { export {
@@ -203,6 +217,21 @@ export {
export { export {
type LocalReconciliationAutomationPlanDependencies, type LocalReconciliationAutomationPlanDependencies,
} from './reconciliation/application/automation/coordinator'; } 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 { export {
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES, MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES, MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
@@ -27,6 +27,9 @@ import {
verifyLocalReconciliationApplicationCommandFile, verifyLocalReconciliationApplicationCommandFile,
planLocalReconciliationAutomationCommandFile, planLocalReconciliationAutomationCommandFile,
verifyLocalReconciliationAutomationPlanCommandFile, verifyLocalReconciliationAutomationPlanCommandFile,
prepareLocalReconciliationAutomationDecisionCommandFile,
commitLocalReconciliationAutomationDecisionCommandFile,
verifyLocalReconciliationAutomationDecisionCommandFile,
writeLocalReconciliationReviewDiagnosticsCommandFile, writeLocalReconciliationReviewDiagnosticsCommandFile,
prepareLocalDeploymentCommandFile, prepareLocalDeploymentCommandFile,
proveLocalDeploymentLegacyReadinessCommandFile, proveLocalDeploymentLegacyReadinessCommandFile,
@@ -42,7 +45,7 @@ import {
} from './localDeployment'; } from './localDeployment';
const USAGE = const USAGE =
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json'; 'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
async function main(argv: readonly string[]): Promise<void> { async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
@@ -86,6 +89,9 @@ async function main(argv: readonly string[]): Promise<void> {
argv[0] !== 'reconciliation-application-verify' && argv[0] !== 'reconciliation-application-verify' &&
argv[0] !== 'reconciliation-automation-plan' && argv[0] !== 'reconciliation-automation-plan' &&
argv[0] !== 'reconciliation-automation-verify' && 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-revision' &&
argv[0] !== 'compose-preflight' && argv[0] !== 'compose-preflight' &&
argv[0] !== 'compose-apply' && argv[0] !== 'compose-apply' &&
@@ -186,6 +192,12 @@ async function main(argv: readonly string[]): Promise<void> {
? planLocalReconciliationAutomationCommandFile(argv[2]!) ? planLocalReconciliationAutomationCommandFile(argv[2]!)
: argv[0] === 'reconciliation-automation-verify' : argv[0] === 'reconciliation-automation-verify'
? verifyLocalReconciliationAutomationPlanCommandFile(argv[2]!) ? 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' : argv[0] === 'compose-revision'
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!) ? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
: argv[0] === 'compose-preflight' : argv[0] === 'compose-preflight'
@@ -609,6 +609,33 @@ function validateTerminalBinding(
} }
} }
export interface LocalReconciliationAutomationTerminal {
readonly receipt: Readonly<LocalReconciliationAutomationPlanReceipt>;
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<LocalReconciliationAutomationTerminal> {
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( export async function planLocalReconciliationAutomation(
value: unknown, value: unknown,
dependencies: LocalReconciliationAutomationPlanDependencies = {}, dependencies: LocalReconciliationAutomationPlanDependencies = {},
@@ -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<LocalReconciliationAutomationDecisionOptions>;
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<LocalReconciliationAutomationDecisionCommitOptions>;
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<LocalReconciliationAutomationDecisionOptions>;
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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
configurationError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
}
function 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<LocalReconciliationAutomationDecisionOptions> {
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<LocalReconciliationAutomationDecisionPrepareCommand> {
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<LocalReconciliationAutomationDecisionCommitCommand> {
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<LocalReconciliationAutomationDecisionVerifyCommand> {
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',
),
}),
});
}
@@ -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<LocalReconciliationAutomationDecisionPrepareCommand>;
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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
configurationError(`${label} must be an object`);
}
const record = value as Record<string, unknown>;
const actual = Object.keys(record).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError(`${label} shape is invalid`);
}
return record;
}
export function buildLocalReconciliationAutomationDecisionIntent(
input: Omit<LocalReconciliationAutomationDecisionIntent, 'schema' | 'schemaVersion' | 'preparationDigest'>,
): Readonly<LocalReconciliationAutomationDecisionIntent> {
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<LocalReconciliationAutomationDecisionIntent> {
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<LocalReconciliationAutomationDecisionIntent>;
}
export function buildLocalReconciliationAutomationDecisionReceipt(
input: Omit<LocalReconciliationAutomationDecisionReceipt, 'schema' | 'schemaVersion' | 'state' | 'decisionDigest'>,
): Readonly<LocalReconciliationAutomationDecisionReceipt> {
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<LocalReconciliationAutomationDecisionReceipt> {
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<LocalReconciliationAutomationDecisionReceipt>;
}
export function localReconciliationAutomationDecisionEvidenceContents(
value:
| Readonly<LocalReconciliationAutomationDecisionIntent>
| Readonly<LocalReconciliationAutomationDecisionReceipt>,
): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
@@ -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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
configurationError(`${label} must be an object`);
}
const record = value as Record<string, unknown>;
const actual = Object.keys(record).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
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<FileLine> {
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<LocalReconciliationAutomationPlanReceipt>,
): Readonly<LocalReconciliationAutomationPlanHeader> {
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<LocalReconciliationAutomationPlanHeader>;
}
function planRow(
value: unknown,
expectedOrdinal: number,
): Readonly<LocalReconciliationAutomationPlanRow> {
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<string, unknown>;
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<LocalReconciliationAutomationPlanRow>;
}
export function readLocalReconciliationAutomationPlanHeader(
filePath: string,
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
uid: number,
): Readonly<LocalReconciliationAutomationPlanHeader> {
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<LocalReconciliationAutomationPlanReceipt>,
uid: number,
): () => Iterable<ReconciliationAutomationDecisionRequirement> {
return () =>
(function* (): Iterable<ReconciliationAutomationDecisionRequirement> {
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<LocalReconciliationAutomationPlanHeader> | 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);
}
})();
}
@@ -337,3 +337,64 @@ export function withLocalReconciliationSealedDatabase<T>(
} }
return output; return output;
} }
export async function withLocalReconciliationSealedDatabaseAsync<T>(
bundle: Readonly<LocalReconciliationSealedBundle>,
kind: LocalReconciliationSealedDatabaseKind,
uid: number,
dependencies: LocalReconciliationSealedBundleReaderDependencies,
read: (client: DatabaseSync) => Promise<T>,
): Promise<T | null> {
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;
}
@@ -756,7 +756,9 @@ function replayResult(
head.state === 'reconciliation_reviewed' || head.state === 'reconciliation_reviewed' ||
head.state === 'reconciliation_application_prepared' || head.state === 'reconciliation_application_prepared' ||
head.state === 'reconciliation_application_planned' || 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 ( if (
record.actionId !== intent.actionId || record.actionId !== intent.actionId ||
record.intentDigest !== intent.intentDigest || record.intentDigest !== intent.intentDigest ||
@@ -10,15 +10,18 @@ const { test } = require('node:test');
const { const {
commitLocalReconciliationCapture, commitLocalReconciliationCapture,
commitLocalReconciliationApplication, commitLocalReconciliationApplication,
commitLocalReconciliationAutomationDecision,
commitLocalReconciliationPlan, commitLocalReconciliationPlan,
commitLocalReconciliationReview, commitLocalReconciliationReview,
prepareLocalReconciliationCapture, prepareLocalReconciliationCapture,
prepareLocalReconciliationApplication, prepareLocalReconciliationApplication,
prepareLocalReconciliationAutomationDecision,
planLocalReconciliationAutomation, planLocalReconciliationAutomation,
prepareLocalReconciliationPlan, prepareLocalReconciliationPlan,
prepareLocalReconciliationReview, prepareLocalReconciliationReview,
verifyLocalReconciliationCapture, verifyLocalReconciliationCapture,
verifyLocalReconciliationApplication, verifyLocalReconciliationApplication,
verifyLocalReconciliationAutomationDecision,
verifyLocalReconciliationAutomationPlan, verifyLocalReconciliationAutomationPlan,
verifyLocalReconciliationPlan, verifyLocalReconciliationPlan,
verifyLocalReconciliationReview, 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) { function dockerReadSealedSqlite(assetsDirectory, mode) {
const source = const source =
mode === 'main_only_immutable' 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); 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) => { test('automation row plan fails closed to manual review on a target task collision', async (t) => {
const state = await reviewedApplicationFixture(t, { const state = await reviewedApplicationFixture(t, {
planId: '00000000-0000-4000-8000-000000000435', planId: '00000000-0000-4000-8000-000000000435',
+21
View File
@@ -2018,6 +2018,21 @@ function auditSourceImports(root, packagePath, findings) {
'@qinglong/local-sqlite/authentication-read', '@qinglong/local-sqlite/authentication-read',
].includes(specifier) ].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) === path.relative(packageDirectory, filePath) ===
'src/deployment/reconciliation/application/automation/rowPlan.ts' && 'src/deployment/reconciliation/application/automation/rowPlan.ts' &&
@@ -2195,6 +2210,12 @@ function auditSourceImports(root, packagePath, findings) {
(path.relative(packageDirectory, filePath) === (path.relative(packageDirectory, filePath) ===
'src/legacy-adoption/legacyCrontabDecisionIssuerKeyring.ts' && 'src/legacy-adoption/legacyCrontabDecisionIssuerKeyring.ts' &&
specifier === '@qinglong/runtime-core/local-secret') || 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) === (path.relative(packageDirectory, filePath) ===
'src/plugin-package/pluginPackageStaging.ts' && 'src/plugin-package/pluginPackageStaging.ts' &&
[ [