mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(local): authorize reconciliation automation decisions
This commit is contained in:
@@ -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",
|
||||
|
||||
+50
-6
@@ -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<LegacyCrontabAdoptionDecision>,
|
||||
@@ -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<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||
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<T>(
|
||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult | T> {
|
||||
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<T>(
|
||||
!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<T>(
|
||||
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',
|
||||
|
||||
@@ -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_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<LocalCutoverInstanceHead> {
|
||||
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') ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <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> {
|
||||
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-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<void> {
|
||||
? 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'
|
||||
|
||||
+27
@@ -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(
|
||||
value: unknown,
|
||||
dependencies: LocalReconciliationAutomationPlanDependencies = {},
|
||||
|
||||
+437
@@ -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',
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1042
File diff suppressed because it is too large
Load Diff
+239
@@ -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`;
|
||||
}
|
||||
+438
@@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+3
-1
@@ -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 ||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' &&
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user