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",
|
"require": "./dist/legacy-adoption/legacyCrontabInspection.js",
|
||||||
"default": "./dist/legacy-adoption/legacyCrontabInspection.js"
|
"default": "./dist/legacy-adoption/legacyCrontabInspection.js"
|
||||||
},
|
},
|
||||||
|
"./reconciliation-automation-decision": {
|
||||||
|
"types": "./dist/legacy-adoption/reconciliationAutomationDecision.d.ts",
|
||||||
|
"require": "./dist/legacy-adoption/reconciliationAutomationDecision.js",
|
||||||
|
"default": "./dist/legacy-adoption/reconciliationAutomationDecision.js"
|
||||||
|
},
|
||||||
"./package-staging": {
|
"./package-staging": {
|
||||||
"types": "./dist/plugin-package/pluginPackageStaging.d.ts",
|
"types": "./dist/plugin-package/pluginPackageStaging.d.ts",
|
||||||
"require": "./dist/plugin-package/pluginPackageStaging.js",
|
"require": "./dist/plugin-package/pluginPackageStaging.js",
|
||||||
|
|||||||
+50
-6
@@ -88,6 +88,8 @@ export interface VerifyLegacyCrontabDecisionAuthorizationFileOptions {
|
|||||||
readonly expectedPlanDigest: string;
|
readonly expectedPlanDigest: string;
|
||||||
readonly expectedInventoryDigest: string;
|
readonly expectedInventoryDigest: string;
|
||||||
readonly keyProvider: LocalSecretKeyProvider;
|
readonly keyProvider: LocalSecretKeyProvider;
|
||||||
|
readonly allowedModes?: readonly (0o400 | 0o600)[];
|
||||||
|
readonly allowedParentModes?: readonly (0o500 | 0o700)[];
|
||||||
readonly verifyReceipt: (
|
readonly verifyReceipt: (
|
||||||
receipt: unknown,
|
receipt: unknown,
|
||||||
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||||
@@ -218,7 +220,47 @@ function currentUid(): number {
|
|||||||
return uid;
|
return uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPrivateParent(filePath: string, uid: number): void {
|
function verificationModes(
|
||||||
|
value: readonly (0o400 | 0o600)[] | undefined,
|
||||||
|
): readonly number[] {
|
||||||
|
if (value === undefined) return Object.freeze([0o600]);
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length < 1 ||
|
||||||
|
value.length > 2 ||
|
||||||
|
value.some((mode) => mode !== 0o400 && mode !== 0o600) ||
|
||||||
|
new Set(value).size !== value.length
|
||||||
|
) {
|
||||||
|
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||||
|
'allowed file modes are invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze([...value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verificationParentModes(
|
||||||
|
value: readonly (0o500 | 0o700)[] | undefined,
|
||||||
|
): readonly number[] {
|
||||||
|
if (value === undefined) return Object.freeze([0o700]);
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length < 1 ||
|
||||||
|
value.length > 2 ||
|
||||||
|
value.some((mode) => mode !== 0o500 && mode !== 0o700) ||
|
||||||
|
new Set(value).size !== value.length
|
||||||
|
) {
|
||||||
|
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||||
|
'allowed parent modes are invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze([...value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPrivateParent(
|
||||||
|
filePath: string,
|
||||||
|
uid: number,
|
||||||
|
allowedModes: readonly number[],
|
||||||
|
): void {
|
||||||
let stat: fs.Stats;
|
let stat: fs.Stats;
|
||||||
try {
|
try {
|
||||||
stat = fs.lstatSync(path.dirname(filePath));
|
stat = fs.lstatSync(path.dirname(filePath));
|
||||||
@@ -232,7 +274,7 @@ function assertPrivateParent(filePath: string, uid: number): void {
|
|||||||
!stat.isDirectory() ||
|
!stat.isDirectory() ||
|
||||||
stat.isSymbolicLink() ||
|
stat.isSymbolicLink() ||
|
||||||
stat.uid !== uid ||
|
stat.uid !== uid ||
|
||||||
(stat.mode & 0o777) !== 0o700
|
!allowedModes.includes(stat.mode & 0o777)
|
||||||
) {
|
) {
|
||||||
throw new LegacyCrontabDecisionAuthorizationFileError(
|
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||||
'parent must be an owner-only real directory',
|
'parent must be an owner-only real directory',
|
||||||
@@ -693,7 +735,7 @@ export async function publishLegacyCrontabDecisionAuthorizationFile(
|
|||||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||||
const filePath = authorizationPath(options.filePath);
|
const filePath = authorizationPath(options.filePath);
|
||||||
const uid = currentUid();
|
const uid = currentUid();
|
||||||
assertPrivateParent(filePath, uid);
|
assertPrivateParent(filePath, uid, [0o700]);
|
||||||
const fileHeader = header(options);
|
const fileHeader = header(options);
|
||||||
const temporary = path.join(
|
const temporary = path.join(
|
||||||
path.dirname(filePath),
|
path.dirname(filePath),
|
||||||
@@ -860,7 +902,9 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
|
|||||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult | T> {
|
): Promise<LegacyCrontabDecisionAuthorizationFileResult | T> {
|
||||||
const filePath = authorizationPath(options.filePath);
|
const filePath = authorizationPath(options.filePath);
|
||||||
const uid = currentUid();
|
const uid = currentUid();
|
||||||
assertPrivateParent(filePath, uid);
|
const allowedModes = verificationModes(options.allowedModes);
|
||||||
|
const allowedParentModes = verificationParentModes(options.allowedParentModes);
|
||||||
|
assertPrivateParent(filePath, uid, allowedParentModes);
|
||||||
assertHeaderIdentity({
|
assertHeaderIdentity({
|
||||||
decisionId: options.expectedDecisionId,
|
decisionId: options.expectedDecisionId,
|
||||||
profile: options.expectedProfile,
|
profile: options.expectedProfile,
|
||||||
@@ -874,7 +918,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
|
|||||||
!before.isFile() ||
|
!before.isFile() ||
|
||||||
before.isSymbolicLink() ||
|
before.isSymbolicLink() ||
|
||||||
Number(before.uid) !== uid ||
|
Number(before.uid) !== uid ||
|
||||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
!allowedModes.includes(Number(before.mode) & 0o777) ||
|
||||||
before.size < 1n ||
|
before.size < 1n ||
|
||||||
before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_AUTHORIZATION_FILE_BYTES)
|
before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_AUTHORIZATION_FILE_BYTES)
|
||||||
) {
|
) {
|
||||||
@@ -1032,7 +1076,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
|
|||||||
afterPath.mtimeNs !== opened.mtimeNs ||
|
afterPath.mtimeNs !== opened.mtimeNs ||
|
||||||
afterPath.ctimeNs !== opened.ctimeNs ||
|
afterPath.ctimeNs !== opened.ctimeNs ||
|
||||||
Number(afterPath.uid) !== uid ||
|
Number(afterPath.uid) !== uid ||
|
||||||
(Number(afterPath.mode) & 0o777) !== 0o600
|
!allowedModes.includes(Number(afterPath.mode) & 0o777)
|
||||||
) {
|
) {
|
||||||
throw new LegacyCrontabDecisionAuthorizationFileError(
|
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||||
'file identity changed during verification',
|
'file identity changed during verification',
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
// Reconciliation Automation owns a plan-bound facade over legacy row decisions.
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
|
||||||
|
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||||
|
import {
|
||||||
|
publishLegacyCrontabDecisionAuthorizationFile,
|
||||||
|
withVerifiedLegacyCrontabDecisionAuthorizationFile,
|
||||||
|
type LegacyCrontabDecisionAuthorizationFileResult,
|
||||||
|
type VerifiedLegacyCrontabDecisionAuthorizationFileScope,
|
||||||
|
} from './legacyCrontabDecisionAuthorizationFile';
|
||||||
|
import {
|
||||||
|
createLegacyCrontabAdoptionDecisionReceipt,
|
||||||
|
verifyLegacyCrontabAdoptionDecisionReceipt,
|
||||||
|
type LegacyCrontabAdoptionDecision,
|
||||||
|
} from './legacyCrontabDecisionReceipt';
|
||||||
|
import type { LegacyCrontabAdoptionClassification } from './legacyCrontabAdoption';
|
||||||
|
import { withPrivateLegacyCrontabAdoptionDecisionReviewFile } from './legacyCrontabDecisionReviewFile';
|
||||||
|
|
||||||
|
export type ReconciliationAutomationDecisionRequirementKind =
|
||||||
|
| 'review_adopt'
|
||||||
|
| 'review_skip_conflict'
|
||||||
|
| 'manual_required';
|
||||||
|
|
||||||
|
export interface ReconciliationAutomationDecisionRequirement {
|
||||||
|
readonly rowOrdinal: number;
|
||||||
|
readonly sourceDigest: string;
|
||||||
|
readonly classification: LegacyCrontabAdoptionClassification;
|
||||||
|
readonly requirement: ReconciliationAutomationDecisionRequirementKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconciliationAutomationDecisionIdentity {
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly automationPlanDigest: string;
|
||||||
|
readonly inventoryDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReconciliationAutomationDecisionVerificationOptions
|
||||||
|
extends ReconciliationAutomationDecisionIdentity {
|
||||||
|
readonly authorizationPath: string;
|
||||||
|
readonly sourceClient: DatabaseSync;
|
||||||
|
readonly timezone: string | null;
|
||||||
|
readonly keyProvider: LocalSecretKeyProvider;
|
||||||
|
readonly observedAtMs: number;
|
||||||
|
readonly openRequirements: () => Iterable<ReconciliationAutomationDecisionRequirement>;
|
||||||
|
readonly allowedModes?: readonly (0o400 | 0o600)[];
|
||||||
|
readonly allowedParentModes?: readonly (0o500 | 0o700)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueReconciliationAutomationDecisionOptions
|
||||||
|
extends ReconciliationAutomationDecisionVerificationOptions {
|
||||||
|
readonly reviewFilePath: string;
|
||||||
|
readonly reviewer: SecurityPrincipal;
|
||||||
|
readonly issuedAtMs: number;
|
||||||
|
readonly expiresAtMs: number;
|
||||||
|
readonly confirmExternalAuthority: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoverReconciliationAutomationDecisionOptions
|
||||||
|
extends ReconciliationAutomationDecisionVerificationOptions {
|
||||||
|
readonly reviewFilePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconciliationAutomationDecisionPublication {
|
||||||
|
readonly authorization: LegacyCrontabDecisionAuthorizationFileResult;
|
||||||
|
readonly reviewFileDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifiedReconciliationAutomationDecisionScope
|
||||||
|
extends VerifiedLegacyCrontabDecisionAuthorizationFileScope {
|
||||||
|
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconciliationAutomationDecisionError extends Error {
|
||||||
|
readonly code = 'RECONCILIATION_AUTOMATION_DECISION_INVALID';
|
||||||
|
|
||||||
|
constructor(message: string, readonly cause?: unknown) {
|
||||||
|
super(`Reconciliation Automation decision is invalid: ${message}`);
|
||||||
|
this.name = 'ReconciliationAutomationDecisionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
const CLASSIFICATIONS = Object.freeze([
|
||||||
|
'lossless',
|
||||||
|
'requires_shell_compatibility',
|
||||||
|
'requires_manual_action',
|
||||||
|
'malformed',
|
||||||
|
] as const);
|
||||||
|
const REQUIREMENTS = Object.freeze([
|
||||||
|
'review_adopt',
|
||||||
|
'review_skip_conflict',
|
||||||
|
'manual_required',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
function requirement(
|
||||||
|
value: ReconciliationAutomationDecisionRequirement,
|
||||||
|
): Readonly<ReconciliationAutomationDecisionRequirement> {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
Object.keys(value).sort().join('\0') !==
|
||||||
|
['classification', 'requirement', 'rowOrdinal', 'sourceDigest']
|
||||||
|
.sort()
|
||||||
|
.join('\0') ||
|
||||||
|
!Number.isSafeInteger(value.rowOrdinal) ||
|
||||||
|
value.rowOrdinal < 1 ||
|
||||||
|
!DIGEST_PATTERN.test(value.sourceDigest) ||
|
||||||
|
!CLASSIFICATIONS.includes(value.classification) ||
|
||||||
|
!REQUIREMENTS.includes(value.requirement)
|
||||||
|
) {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'plan requirement is invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ ...value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function iterator<T>(value: Iterable<T>, label: string): Iterator<T> {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
(typeof value !== 'object' && typeof value !== 'function') ||
|
||||||
|
typeof value[Symbol.iterator] !== 'function'
|
||||||
|
) {
|
||||||
|
throw new ReconciliationAutomationDecisionError(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
const selected = value[Symbol.iterator]();
|
||||||
|
if (!selected || typeof selected.next !== 'function') {
|
||||||
|
throw new ReconciliationAutomationDecisionError(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionsBoundToPlan(
|
||||||
|
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||||
|
openRequirements: () => Iterable<ReconciliationAutomationDecisionRequirement>,
|
||||||
|
): Iterable<LegacyCrontabAdoptionDecision> {
|
||||||
|
if (typeof openRequirements !== 'function') {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'plan requirement factory is invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (function* (): Iterable<LegacyCrontabAdoptionDecision> {
|
||||||
|
const decisionIterator = iterator(decisions, 'decision stream');
|
||||||
|
const requirementIterator = iterator(
|
||||||
|
openRequirements(),
|
||||||
|
'plan requirement stream',
|
||||||
|
);
|
||||||
|
let complete = false;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const nextDecision = decisionIterator.next();
|
||||||
|
const nextRequirement = requirementIterator.next();
|
||||||
|
if (nextDecision.done || nextRequirement.done) {
|
||||||
|
if (nextDecision.done !== nextRequirement.done) {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'decision and plan row counts differ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
complete = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expected = requirement(nextRequirement.value);
|
||||||
|
const decision = nextDecision.value;
|
||||||
|
if (
|
||||||
|
decision.rowOrdinal !== expected.rowOrdinal ||
|
||||||
|
decision.sourceDigest !== expected.sourceDigest
|
||||||
|
) {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'decision is detached from its plan row',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expected.requirement !== 'review_adopt' &&
|
||||||
|
decision.disposition !== 'skip'
|
||||||
|
) {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'conflict or manual row cannot be adopted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
yield decision;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!complete) {
|
||||||
|
try {
|
||||||
|
decisionIterator.return?.();
|
||||||
|
} catch {
|
||||||
|
// Preserve the binding failure.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
requirementIterator.return?.();
|
||||||
|
} catch {
|
||||||
|
// Preserve the binding failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDecision(
|
||||||
|
left: LegacyCrontabAdoptionDecision,
|
||||||
|
right: LegacyCrontabAdoptionDecision,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.rowOrdinal === right.rowOrdinal &&
|
||||||
|
left.sourceDigest === right.sourceDigest &&
|
||||||
|
left.disposition === right.disposition &&
|
||||||
|
left.reason === right.reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifiedOptions(
|
||||||
|
options: ReconciliationAutomationDecisionVerificationOptions,
|
||||||
|
openRequirements: () => Iterable<ReconciliationAutomationDecisionRequirement>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
filePath: options.authorizationPath,
|
||||||
|
expectedDecisionId: options.decisionId,
|
||||||
|
expectedProfile: options.profile,
|
||||||
|
expectedPlanDigest: options.automationPlanDigest,
|
||||||
|
expectedInventoryDigest: options.inventoryDigest,
|
||||||
|
keyProvider: options.keyProvider,
|
||||||
|
...(options.allowedModes === undefined
|
||||||
|
? {}
|
||||||
|
: { allowedModes: options.allowedModes }),
|
||||||
|
...(options.allowedParentModes === undefined
|
||||||
|
? {}
|
||||||
|
: { allowedParentModes: options.allowedParentModes }),
|
||||||
|
verifyReceipt: (
|
||||||
|
receipt: unknown,
|
||||||
|
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||||
|
) =>
|
||||||
|
verifyLegacyCrontabAdoptionDecisionReceipt(
|
||||||
|
options.sourceClient,
|
||||||
|
options.timezone,
|
||||||
|
receipt,
|
||||||
|
decisionsBoundToPlan(decisions, openRequirements),
|
||||||
|
options.observedAtMs,
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueReconciliationAutomationDecision(
|
||||||
|
options: IssueReconciliationAutomationDecisionOptions,
|
||||||
|
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
|
||||||
|
try {
|
||||||
|
return await withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||||
|
{
|
||||||
|
filePath: options.reviewFilePath,
|
||||||
|
expectedDecisionId: options.decisionId,
|
||||||
|
expectedProfile: options.profile,
|
||||||
|
expectedPlanDigest: options.automationPlanDigest,
|
||||||
|
expectedInventoryDigest: options.inventoryDigest,
|
||||||
|
},
|
||||||
|
async (review) => {
|
||||||
|
const authorization =
|
||||||
|
await publishLegacyCrontabDecisionAuthorizationFile({
|
||||||
|
filePath: options.authorizationPath,
|
||||||
|
decisionId: options.decisionId,
|
||||||
|
profile: options.profile,
|
||||||
|
planDigest: options.automationPlanDigest,
|
||||||
|
inventoryDigest: options.inventoryDigest,
|
||||||
|
decisions: decisionsBoundToPlan(
|
||||||
|
review.decisions,
|
||||||
|
options.openRequirements,
|
||||||
|
),
|
||||||
|
keyProvider: options.keyProvider,
|
||||||
|
createReceipt: (decisions) =>
|
||||||
|
createLegacyCrontabAdoptionDecisionReceipt(
|
||||||
|
options.sourceClient,
|
||||||
|
options.timezone,
|
||||||
|
{
|
||||||
|
decisionId: options.decisionId,
|
||||||
|
profile: options.profile,
|
||||||
|
planDigest: options.automationPlanDigest,
|
||||||
|
inventoryDigest: options.inventoryDigest,
|
||||||
|
reviewer: options.reviewer,
|
||||||
|
issuedAtMs: options.issuedAtMs,
|
||||||
|
expiresAtMs: options.expiresAtMs,
|
||||||
|
},
|
||||||
|
decisions,
|
||||||
|
),
|
||||||
|
async confirmExternalAuthority() {
|
||||||
|
review.confirmIdentity();
|
||||||
|
await options.confirmExternalAuthority();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
review.confirmIdentity();
|
||||||
|
return Object.freeze({
|
||||||
|
authorization,
|
||||||
|
reviewFileDigest: review.evidence.fileDigest,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ReconciliationAutomationDecisionError) throw error;
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'authorization could not be issued',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverReconciliationAutomationDecision(
|
||||||
|
options: RecoverReconciliationAutomationDecisionOptions,
|
||||||
|
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
|
||||||
|
try {
|
||||||
|
return await withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||||
|
{
|
||||||
|
filePath: options.reviewFilePath,
|
||||||
|
expectedDecisionId: options.decisionId,
|
||||||
|
expectedProfile: options.profile,
|
||||||
|
expectedPlanDigest: options.automationPlanDigest,
|
||||||
|
expectedInventoryDigest: options.inventoryDigest,
|
||||||
|
},
|
||||||
|
async (review) =>
|
||||||
|
withVerifiedLegacyCrontabDecisionAuthorizationFile(
|
||||||
|
verifiedOptions(options, options.openRequirements),
|
||||||
|
async (authorization) => {
|
||||||
|
const reviewed = iterator(
|
||||||
|
decisionsBoundToPlan(
|
||||||
|
review.decisions,
|
||||||
|
options.openRequirements,
|
||||||
|
),
|
||||||
|
'review decision stream',
|
||||||
|
);
|
||||||
|
const signed = iterator(
|
||||||
|
authorization.decisions,
|
||||||
|
'signed decision stream',
|
||||||
|
);
|
||||||
|
for (;;) {
|
||||||
|
const left = reviewed.next();
|
||||||
|
const right = signed.next();
|
||||||
|
if (left.done || right.done) {
|
||||||
|
if (left.done !== right.done) {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'review and signed decision counts differ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!sameDecision(left.value, right.value)) {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'review decision differs from signed authorization',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
review.confirmIdentity();
|
||||||
|
authorization.confirmIdentity();
|
||||||
|
return Object.freeze({
|
||||||
|
authorization: authorization.result,
|
||||||
|
reviewFileDigest: review.evidence.fileDigest,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ReconciliationAutomationDecisionError) throw error;
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'authorization recovery failed',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withVerifiedReconciliationAutomationDecision<T>(
|
||||||
|
options: ReconciliationAutomationDecisionVerificationOptions,
|
||||||
|
consumer: (
|
||||||
|
scope: VerifiedReconciliationAutomationDecisionScope,
|
||||||
|
) => T | Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (typeof consumer !== 'function') {
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'verified decision consumer is invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await withVerifiedLegacyCrontabDecisionAuthorizationFile(
|
||||||
|
verifiedOptions(options, options.openRequirements),
|
||||||
|
(scope) =>
|
||||||
|
consumer(
|
||||||
|
Object.freeze({
|
||||||
|
...scope,
|
||||||
|
decisions: decisionsBoundToPlan(
|
||||||
|
scope.decisions,
|
||||||
|
options.openRequirements,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ReconciliationAutomationDecisionError) throw error;
|
||||||
|
throw new ReconciliationAutomationDecisionError(
|
||||||
|
'authorization verification failed',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyReconciliationAutomationDecision(
|
||||||
|
options: ReconciliationAutomationDecisionVerificationOptions,
|
||||||
|
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||||
|
return withVerifiedReconciliationAutomationDecision(options, (scope) => {
|
||||||
|
for (const _decision of scope.decisions) {
|
||||||
|
// Full consumption proves the second plan-bound stream as well.
|
||||||
|
}
|
||||||
|
scope.confirmIdentity();
|
||||||
|
return scope.result;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ export type LocalCutoverInstanceHeadState =
|
|||||||
| 'reconciliation_application_prepared'
|
| 'reconciliation_application_prepared'
|
||||||
| 'reconciliation_application_planned'
|
| 'reconciliation_application_planned'
|
||||||
| 'reconciliation_automation_planned'
|
| 'reconciliation_automation_planned'
|
||||||
|
| 'reconciliation_automation_decision_prepared'
|
||||||
|
| 'reconciliation_automation_reviewed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -172,6 +174,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
|||||||
head.state !== 'reconciliation_application_prepared' &&
|
head.state !== 'reconciliation_application_prepared' &&
|
||||||
head.state !== 'reconciliation_application_planned' &&
|
head.state !== 'reconciliation_application_planned' &&
|
||||||
head.state !== 'reconciliation_automation_planned' &&
|
head.state !== 'reconciliation_automation_planned' &&
|
||||||
|
head.state !== 'reconciliation_automation_decision_prepared' &&
|
||||||
|
head.state !== 'reconciliation_automation_reviewed' &&
|
||||||
head.state !== 'rollback_prepared' &&
|
head.state !== 'rollback_prepared' &&
|
||||||
head.state !== 'legacy_restart_requested' &&
|
head.state !== 'legacy_restart_requested' &&
|
||||||
head.state !== 'legacy_running' &&
|
head.state !== 'legacy_running' &&
|
||||||
@@ -347,6 +351,8 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
| 'reconciliation_application_prepared'
|
| 'reconciliation_application_prepared'
|
||||||
| 'reconciliation_application_planned'
|
| 'reconciliation_application_planned'
|
||||||
| 'reconciliation_automation_planned'
|
| 'reconciliation_automation_planned'
|
||||||
|
| 'reconciliation_automation_decision_prepared'
|
||||||
|
| 'reconciliation_automation_reviewed'
|
||||||
| 'rollback_prepared'
|
| 'rollback_prepared'
|
||||||
| 'legacy_restart_requested'
|
| 'legacy_restart_requested'
|
||||||
| 'legacy_running'
|
| 'legacy_running'
|
||||||
@@ -394,6 +400,8 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_application_prepared' ||
|
current.state === 'reconciliation_application_prepared' ||
|
||||||
current.state === 'reconciliation_application_planned' ||
|
current.state === 'reconciliation_application_planned' ||
|
||||||
current.state === 'reconciliation_automation_planned' ||
|
current.state === 'reconciliation_automation_planned' ||
|
||||||
|
current.state === 'reconciliation_automation_decision_prepared' ||
|
||||||
|
current.state === 'reconciliation_automation_reviewed' ||
|
||||||
current.state === 'legacy_restart_requested' ||
|
current.state === 'legacy_restart_requested' ||
|
||||||
current.state === 'legacy_running' ||
|
current.state === 'legacy_running' ||
|
||||||
current.state === 'legacy_ready')
|
current.state === 'legacy_ready')
|
||||||
@@ -427,6 +435,10 @@ export function advanceLocalCutoverInstanceHead(
|
|||||||
current.state === 'reconciliation_application_prepared') ||
|
current.state === 'reconciliation_application_prepared') ||
|
||||||
(state === 'reconciliation_automation_planned' &&
|
(state === 'reconciliation_automation_planned' &&
|
||||||
current.state === 'reconciliation_application_planned') ||
|
current.state === 'reconciliation_application_planned') ||
|
||||||
|
(state === 'reconciliation_automation_decision_prepared' &&
|
||||||
|
current.state === 'reconciliation_automation_planned') ||
|
||||||
|
(state === 'reconciliation_automation_reviewed' &&
|
||||||
|
current.state === 'reconciliation_automation_decision_prepared') ||
|
||||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||||
(state === 'legacy_restart_requested' &&
|
(state === 'legacy_restart_requested' &&
|
||||||
current.state === 'rollback_prepared') ||
|
current.state === 'rollback_prepared') ||
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ import {
|
|||||||
verifyLocalReconciliationAutomationPlan,
|
verifyLocalReconciliationAutomationPlan,
|
||||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||||
} from './reconciliation/application/automation/coordinator';
|
} from './reconciliation/application/automation/coordinator';
|
||||||
|
import {
|
||||||
|
commitLocalReconciliationAutomationDecision,
|
||||||
|
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
prepareLocalReconciliationAutomationDecision,
|
||||||
|
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
verifyLocalReconciliationAutomationDecision,
|
||||||
|
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
} from './reconciliation/application/automation/decisionCoordinator';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
@@ -163,6 +171,12 @@ export {
|
|||||||
planLocalReconciliationAutomationCommandFile,
|
planLocalReconciliationAutomationCommandFile,
|
||||||
verifyLocalReconciliationAutomationPlan,
|
verifyLocalReconciliationAutomationPlan,
|
||||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||||
|
prepareLocalReconciliationAutomationDecision,
|
||||||
|
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
commitLocalReconciliationAutomationDecision,
|
||||||
|
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
verifyLocalReconciliationAutomationDecision,
|
||||||
|
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -203,6 +217,21 @@ export {
|
|||||||
export {
|
export {
|
||||||
type LocalReconciliationAutomationPlanDependencies,
|
type LocalReconciliationAutomationPlanDependencies,
|
||||||
} from './reconciliation/application/automation/coordinator';
|
} from './reconciliation/application/automation/coordinator';
|
||||||
|
export {
|
||||||
|
normalizeLocalReconciliationAutomationDecisionCommitCommand,
|
||||||
|
normalizeLocalReconciliationAutomationDecisionPrepareCommand,
|
||||||
|
normalizeLocalReconciliationAutomationDecisionVerifyCommand,
|
||||||
|
type LocalReconciliationAutomationDecisionCommitCommand,
|
||||||
|
type LocalReconciliationAutomationDecisionCommitOptions,
|
||||||
|
type LocalReconciliationAutomationDecisionOptions,
|
||||||
|
type LocalReconciliationAutomationDecisionPrepareCommand,
|
||||||
|
type LocalReconciliationAutomationDecisionPrepareResult,
|
||||||
|
type LocalReconciliationAutomationDecisionTerminalResult,
|
||||||
|
type LocalReconciliationAutomationDecisionVerifyCommand,
|
||||||
|
} from './reconciliation/application/automation/decisionContract';
|
||||||
|
export {
|
||||||
|
type LocalReconciliationAutomationDecisionDependencies,
|
||||||
|
} from './reconciliation/application/automation/decisionCoordinator';
|
||||||
export {
|
export {
|
||||||
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||||
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
verifyLocalReconciliationApplicationCommandFile,
|
verifyLocalReconciliationApplicationCommandFile,
|
||||||
planLocalReconciliationAutomationCommandFile,
|
planLocalReconciliationAutomationCommandFile,
|
||||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||||
|
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||||
|
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||||
prepareLocalDeploymentCommandFile,
|
prepareLocalDeploymentCommandFile,
|
||||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||||
@@ -42,7 +45,7 @@ import {
|
|||||||
} from './localDeployment';
|
} from './localDeployment';
|
||||||
|
|
||||||
const USAGE =
|
const USAGE =
|
||||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||||
|
|
||||||
async function main(argv: readonly string[]): Promise<void> {
|
async function main(argv: readonly string[]): Promise<void> {
|
||||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||||
@@ -86,6 +89,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
argv[0] !== 'reconciliation-application-verify' &&
|
argv[0] !== 'reconciliation-application-verify' &&
|
||||||
argv[0] !== 'reconciliation-automation-plan' &&
|
argv[0] !== 'reconciliation-automation-plan' &&
|
||||||
argv[0] !== 'reconciliation-automation-verify' &&
|
argv[0] !== 'reconciliation-automation-verify' &&
|
||||||
|
argv[0] !== 'reconciliation-automation-decision-prepare' &&
|
||||||
|
argv[0] !== 'reconciliation-automation-decision-commit' &&
|
||||||
|
argv[0] !== 'reconciliation-automation-decision-verify' &&
|
||||||
argv[0] !== 'compose-revision' &&
|
argv[0] !== 'compose-revision' &&
|
||||||
argv[0] !== 'compose-preflight' &&
|
argv[0] !== 'compose-preflight' &&
|
||||||
argv[0] !== 'compose-apply' &&
|
argv[0] !== 'compose-apply' &&
|
||||||
@@ -186,6 +192,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
? planLocalReconciliationAutomationCommandFile(argv[2]!)
|
? planLocalReconciliationAutomationCommandFile(argv[2]!)
|
||||||
: argv[0] === 'reconciliation-automation-verify'
|
: argv[0] === 'reconciliation-automation-verify'
|
||||||
? verifyLocalReconciliationAutomationPlanCommandFile(argv[2]!)
|
? verifyLocalReconciliationAutomationPlanCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-automation-decision-prepare'
|
||||||
|
? prepareLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-automation-decision-commit'
|
||||||
|
? commitLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||||
|
: argv[0] === 'reconciliation-automation-decision-verify'
|
||||||
|
? verifyLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-revision'
|
: argv[0] === 'compose-revision'
|
||||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||||
: argv[0] === 'compose-preflight'
|
: argv[0] === 'compose-preflight'
|
||||||
|
|||||||
+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(
|
export async function planLocalReconciliationAutomation(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
dependencies: LocalReconciliationAutomationPlanDependencies = {},
|
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;
|
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_reviewed' ||
|
||||||
head.state === 'reconciliation_application_prepared' ||
|
head.state === 'reconciliation_application_prepared' ||
|
||||||
head.state === 'reconciliation_application_planned' ||
|
head.state === 'reconciliation_application_planned' ||
|
||||||
head.state === 'reconciliation_automation_planned');
|
head.state === 'reconciliation_automation_planned' ||
|
||||||
|
head.state === 'reconciliation_automation_decision_prepared' ||
|
||||||
|
head.state === 'reconciliation_automation_reviewed');
|
||||||
if (
|
if (
|
||||||
record.actionId !== intent.actionId ||
|
record.actionId !== intent.actionId ||
|
||||||
record.intentDigest !== intent.intentDigest ||
|
record.intentDigest !== intent.intentDigest ||
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ const { test } = require('node:test');
|
|||||||
const {
|
const {
|
||||||
commitLocalReconciliationCapture,
|
commitLocalReconciliationCapture,
|
||||||
commitLocalReconciliationApplication,
|
commitLocalReconciliationApplication,
|
||||||
|
commitLocalReconciliationAutomationDecision,
|
||||||
commitLocalReconciliationPlan,
|
commitLocalReconciliationPlan,
|
||||||
commitLocalReconciliationReview,
|
commitLocalReconciliationReview,
|
||||||
prepareLocalReconciliationCapture,
|
prepareLocalReconciliationCapture,
|
||||||
prepareLocalReconciliationApplication,
|
prepareLocalReconciliationApplication,
|
||||||
|
prepareLocalReconciliationAutomationDecision,
|
||||||
planLocalReconciliationAutomation,
|
planLocalReconciliationAutomation,
|
||||||
prepareLocalReconciliationPlan,
|
prepareLocalReconciliationPlan,
|
||||||
prepareLocalReconciliationReview,
|
prepareLocalReconciliationReview,
|
||||||
verifyLocalReconciliationCapture,
|
verifyLocalReconciliationCapture,
|
||||||
verifyLocalReconciliationApplication,
|
verifyLocalReconciliationApplication,
|
||||||
|
verifyLocalReconciliationAutomationDecision,
|
||||||
verifyLocalReconciliationAutomationPlan,
|
verifyLocalReconciliationAutomationPlan,
|
||||||
verifyLocalReconciliationPlan,
|
verifyLocalReconciliationPlan,
|
||||||
verifyLocalReconciliationReview,
|
verifyLocalReconciliationReview,
|
||||||
@@ -1069,6 +1072,236 @@ function applicationCommitCommand(state, prepared) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function plannedAutomationFixture(t, options = {}) {
|
||||||
|
const suffix = options.suffix ?? 'decision';
|
||||||
|
const state = await reviewedApplicationFixture(t, {
|
||||||
|
planId: options.planId,
|
||||||
|
reviewId: options.reviewId,
|
||||||
|
applicationId: options.applicationId,
|
||||||
|
reviewSuffix: `automation-decision-${suffix}`,
|
||||||
|
createDefaultSidecars: false,
|
||||||
|
initializeDatabases: automationDatabaseInitializer(),
|
||||||
|
mutateTarget(paths) {
|
||||||
|
return mutateAutomationTarget(paths, options.occupied === true);
|
||||||
|
},
|
||||||
|
mutateDecisions(records) {
|
||||||
|
const selected = records.find(
|
||||||
|
(record) =>
|
||||||
|
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||||
|
record.database === 'legacy' &&
|
||||||
|
record.domain === 'automation' &&
|
||||||
|
record.factKind === 'table' &&
|
||||||
|
record.disposition === 'exclude_legacy',
|
||||||
|
);
|
||||||
|
assert.ok(selected);
|
||||||
|
selected.disposition = options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
||||||
|
selected.reason = options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const preparedApplication = await prepareLocalReconciliationApplication(
|
||||||
|
state.prepareApplicationCommand,
|
||||||
|
);
|
||||||
|
const application = await commitLocalReconciliationApplication(
|
||||||
|
applicationCommitCommand(state, preparedApplication),
|
||||||
|
);
|
||||||
|
const automationRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
`automation-decision-plan-${suffix}`,
|
||||||
|
);
|
||||||
|
const automationDecisionRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
`automation-decision-authority-${suffix}`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(automationRoot, { mode: 0o700 });
|
||||||
|
fs.mkdirSync(automationDecisionRoot, { mode: 0o700 });
|
||||||
|
const automationId =
|
||||||
|
options.automationId ?? '00000000-0000-4000-8000-000000000461';
|
||||||
|
const automationCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.plan',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
automationRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
automationId,
|
||||||
|
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||||
|
expectedApplicationPlanDigest: application.applicationPlanDigest,
|
||||||
|
expectedHeadDigest: application.instanceHeadDigest,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
projectId: 'default',
|
||||||
|
legacyTimezone: 'Asia/Shanghai',
|
||||||
|
preparedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const planned = await planLocalReconciliationAutomation(automationCommand);
|
||||||
|
const automationDirectory = path.join(automationRoot, automationId);
|
||||||
|
const planReceipt = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(automationDirectory, 'receipt.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const planRows = fs
|
||||||
|
.readFileSync(path.join(automationDirectory, 'plan.ndjson'), 'utf8')
|
||||||
|
.trimEnd()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
record.kind === 'qinglong3-local-reconciliation-automation-plan-row',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
application,
|
||||||
|
automationRoot,
|
||||||
|
automationDecisionRoot,
|
||||||
|
automationCommand,
|
||||||
|
automationDirectory,
|
||||||
|
planned,
|
||||||
|
planReceipt,
|
||||||
|
planRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationDecisionReviewFile(
|
||||||
|
state,
|
||||||
|
decisionId,
|
||||||
|
disposition,
|
||||||
|
reason,
|
||||||
|
suffix = 'decision',
|
||||||
|
) {
|
||||||
|
assert.equal(state.planRows.length, 1);
|
||||||
|
const row = state.planRows[0];
|
||||||
|
const records = [
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
kind: 'qinglong3-legacy-crontab-decision-review-file-header',
|
||||||
|
decisionId,
|
||||||
|
profile: state.captureCommand.request.profile,
|
||||||
|
planDigest: state.planned.automationPlanDigest,
|
||||||
|
inventoryDigest: state.planReceipt.legacyInventoryDigest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
kind: 'qinglong3-legacy-crontab-decision-review-file-row',
|
||||||
|
decision: {
|
||||||
|
rowOrdinal: row.rowOrdinal,
|
||||||
|
sourceDigest: row.sourceDigest,
|
||||||
|
disposition,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const filePath = path.join(
|
||||||
|
state.diagnosticRoot,
|
||||||
|
`automation-row-decision-${suffix}.ndjson`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
filePath,
|
||||||
|
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
return { filePath, records };
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationDecisionPrepareCommand(state, decisionId) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.decision.prepare',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
automationRoot: state.automationRoot,
|
||||||
|
automationDecisionRoot: state.automationDecisionRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
expectedAutomationPlanDigest: state.planned.automationPlanDigest,
|
||||||
|
expectedHeadDigest: state.planned.instanceHeadDigest,
|
||||||
|
preparedAtMs: state.automationCommand.request.preparedAtMs + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationDecisionCommitFixture(
|
||||||
|
state,
|
||||||
|
prepared,
|
||||||
|
decisionFilePath,
|
||||||
|
options = {},
|
||||||
|
) {
|
||||||
|
const committedAtMs = options.committedAtMs ?? Date.now();
|
||||||
|
const authorizationLifetimeMs = 10 * 60 * 1_000;
|
||||||
|
let authentications = 0;
|
||||||
|
let confirmations = 0;
|
||||||
|
let databaseCloses = 0;
|
||||||
|
const command = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.decision.commit',
|
||||||
|
options: {
|
||||||
|
...prepared.commandOptions,
|
||||||
|
targetDatabasePath: state.targetDatabasePath,
|
||||||
|
ownerPepperKeyringDirectory: state.command.options.ownerPepperKeyringDirectory,
|
||||||
|
credentialFilePath: state.command.options.credentialFilePath,
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
decisionId: prepared.result.decisionId,
|
||||||
|
automationId: prepared.result.automationId,
|
||||||
|
expectedPreparationDigest: prepared.result.preparationDigest,
|
||||||
|
expectedHeadDigest: prepared.result.instanceHeadDigest,
|
||||||
|
decisionFilePath,
|
||||||
|
committedAtMs,
|
||||||
|
authorizationLifetimeMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dependencies = {
|
||||||
|
now: () => committedAtMs,
|
||||||
|
async openAuthenticationDatabase() {
|
||||||
|
return {
|
||||||
|
async close() {
|
||||||
|
databaseCloses += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async authenticate(_database, authenticateOptions) {
|
||||||
|
authentications += 1;
|
||||||
|
assert.equal(
|
||||||
|
authenticateOptions.authenticationNamespace,
|
||||||
|
'local_reconciliation_automation',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
principal: {
|
||||||
|
subject: {
|
||||||
|
type: 'user',
|
||||||
|
id: options.reviewerId ?? 'review-owner',
|
||||||
|
},
|
||||||
|
authenticationId: 'local_reconciliation_automation:test',
|
||||||
|
authenticatedAtMs: committedAtMs,
|
||||||
|
expiresAtMs: committedAtMs + authorizationLifetimeMs + 60_000,
|
||||||
|
assurance: options.assurance ?? 'local_console',
|
||||||
|
},
|
||||||
|
databaseFence: {
|
||||||
|
credentialId: options.reviewerId ?? 'review-owner',
|
||||||
|
credentialVersion: 1,
|
||||||
|
pepperKeyId: 'review-owner-v1',
|
||||||
|
pepperVersion: 1,
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
confirmations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
dependencies,
|
||||||
|
authenticationCount: () => authentications,
|
||||||
|
confirmationCount: () => confirmations,
|
||||||
|
databaseCloseCount: () => databaseCloses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||||
const source =
|
const source =
|
||||||
mode === 'main_only_immutable'
|
mode === 'main_only_immutable'
|
||||||
@@ -2754,6 +2987,429 @@ test('automation adapter builds a sealed row plan with bounded conflict evidence
|
|||||||
assert.equal(cli.stdout.includes('legacy-cron:1'), false);
|
assert.equal(cli.stdout.includes('legacy-cron:1'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('automation decision reauthenticates the same reviewer, seals exact row decisions and verifies content-free', async (t) => {
|
||||||
|
const state = await plannedAutomationFixture(t, {
|
||||||
|
suffix: 'signed-success',
|
||||||
|
planId: '00000000-0000-4000-8000-000000000461',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000462',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000463',
|
||||||
|
automationId: '00000000-0000-4000-8000-000000000464',
|
||||||
|
});
|
||||||
|
assert.equal(state.planRows[0].requirement, 'review_adopt');
|
||||||
|
const decisionId = '019b0000-0000-7000-8000-000000000461';
|
||||||
|
const review = automationDecisionReviewFile(
|
||||||
|
state,
|
||||||
|
decisionId,
|
||||||
|
'adopt',
|
||||||
|
'reviewed_lossless',
|
||||||
|
'signed-success',
|
||||||
|
);
|
||||||
|
const prepareCommand = automationDecisionPrepareCommand(state, decisionId);
|
||||||
|
const prepared = await prepareLocalReconciliationAutomationDecision(
|
||||||
|
prepareCommand,
|
||||||
|
);
|
||||||
|
assert.equal(prepared.status, 'prepared');
|
||||||
|
assert.equal(prepared.state, 'reconciliation_automation_decision_prepared');
|
||||||
|
const commit = automationDecisionCommitFixture(
|
||||||
|
state,
|
||||||
|
{ result: prepared, commandOptions: prepareCommand.options },
|
||||||
|
review.filePath,
|
||||||
|
);
|
||||||
|
const targetBytes = fs.readFileSync(state.targetDatabasePath);
|
||||||
|
const committed = await commitLocalReconciliationAutomationDecision(
|
||||||
|
commit.command,
|
||||||
|
commit.dependencies,
|
||||||
|
);
|
||||||
|
assert.equal(committed.status, 'prepared');
|
||||||
|
assert.equal(committed.state, 'reconciliation_automation_reviewed');
|
||||||
|
assert.equal(committed.rowCount, 1);
|
||||||
|
assert.equal(committed.adoptedCount, 1);
|
||||||
|
assert.equal(committed.skippedCount, 0);
|
||||||
|
assert.equal(commit.authenticationCount(), 1);
|
||||||
|
assert.equal(commit.confirmationCount(), 1);
|
||||||
|
assert.equal(commit.databaseCloseCount(), 1);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(state.targetDatabasePath).equals(targetBytes),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const decisionDirectory = path.join(
|
||||||
|
state.automationDecisionRoot,
|
||||||
|
state.automationCommand.request.automationId,
|
||||||
|
);
|
||||||
|
assert.deepEqual(fs.readdirSync(decisionDirectory).sort(), [
|
||||||
|
'authorization.ndjson',
|
||||||
|
'intent.json',
|
||||||
|
'receipt.json',
|
||||||
|
'staging',
|
||||||
|
]);
|
||||||
|
assert.equal(fs.statSync(decisionDirectory).mode & 0o777, 0o500);
|
||||||
|
assert.equal(
|
||||||
|
fs.statSync(path.join(decisionDirectory, 'staging')).mode & 0o777,
|
||||||
|
0o500,
|
||||||
|
);
|
||||||
|
for (const name of ['authorization.ndjson', 'intent.json', 'receipt.json']) {
|
||||||
|
assert.equal(
|
||||||
|
fs.statSync(path.join(decisionDirectory, name)).mode & 0o777,
|
||||||
|
0o400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const verifyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.decision.verify',
|
||||||
|
options: prepareCommand.options,
|
||||||
|
request: {
|
||||||
|
decisionId,
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
expectedDecisionDigest: committed.decisionDigest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const verified = await verifyLocalReconciliationAutomationDecision(
|
||||||
|
verifyCommand,
|
||||||
|
);
|
||||||
|
assert.equal(verified.status, 'verified');
|
||||||
|
assert.equal(verified.signedDecisionSetDigest, committed.signedDecisionSetDigest);
|
||||||
|
const serialized = JSON.stringify(verified);
|
||||||
|
assert.equal(serialized.includes('review-owner'), false);
|
||||||
|
assert.equal(serialized.includes(state.planRows[0].sourceDigest), false);
|
||||||
|
assert.equal(serialized.includes(review.filePath), false);
|
||||||
|
const commandPath = path.join(
|
||||||
|
state.deploymentRoot,
|
||||||
|
'automation-decision-verify.json',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const cli = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||||
|
'reconciliation-automation-decision-verify',
|
||||||
|
'--command-file',
|
||||||
|
commandPath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(cli.status, 0, cli.stderr);
|
||||||
|
assert.equal(JSON.parse(cli.stdout).status, 'verified');
|
||||||
|
assert.equal(cli.stdout.includes('review-owner'), false);
|
||||||
|
assert.equal(cli.stdout.includes(state.planRows[0].sourceDigest), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automation decision rejects conflict adoption, another reviewer and weak assurance', async (t) => {
|
||||||
|
const conflict = await plannedAutomationFixture(t, {
|
||||||
|
suffix: 'conflict-reject',
|
||||||
|
occupied: true,
|
||||||
|
planId: '00000000-0000-4000-8000-000000000465',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000466',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000467',
|
||||||
|
automationId: '00000000-0000-4000-8000-000000000468',
|
||||||
|
});
|
||||||
|
assert.equal(conflict.planRows[0].requirement, 'review_skip_conflict');
|
||||||
|
const conflictDecisionId = '019b0000-0000-7000-8000-000000000465';
|
||||||
|
const conflictReview = automationDecisionReviewFile(
|
||||||
|
conflict,
|
||||||
|
conflictDecisionId,
|
||||||
|
'adopt',
|
||||||
|
'reviewed_lossless',
|
||||||
|
'conflict-reject',
|
||||||
|
);
|
||||||
|
const conflictPrepareCommand = automationDecisionPrepareCommand(
|
||||||
|
conflict,
|
||||||
|
conflictDecisionId,
|
||||||
|
);
|
||||||
|
const conflictPrepared = await prepareLocalReconciliationAutomationDecision(
|
||||||
|
conflictPrepareCommand,
|
||||||
|
);
|
||||||
|
const conflictCommit = automationDecisionCommitFixture(
|
||||||
|
conflict,
|
||||||
|
{
|
||||||
|
result: conflictPrepared,
|
||||||
|
commandOptions: conflictPrepareCommand.options,
|
||||||
|
},
|
||||||
|
conflictReview.filePath,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
commitLocalReconciliationAutomationDecision(
|
||||||
|
conflictCommit.command,
|
||||||
|
conflictCommit.dependencies,
|
||||||
|
),
|
||||||
|
(error) => {
|
||||||
|
const messages = [];
|
||||||
|
for (let current = error; current; current = current.cause) {
|
||||||
|
messages.push(String(current.message));
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
messages.join('\n'),
|
||||||
|
/conflict or manual row cannot be adopted/,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(
|
||||||
|
conflict.automationDecisionRoot,
|
||||||
|
conflict.automationCommand.request.automationId,
|
||||||
|
'authorization.ndjson',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const identity = await plannedAutomationFixture(t, {
|
||||||
|
suffix: 'identity-reject',
|
||||||
|
planId: '00000000-0000-4000-8000-000000000469',
|
||||||
|
reviewId: '00000000-0000-4000-8000-00000000046a',
|
||||||
|
applicationId: '00000000-0000-4000-8000-00000000046b',
|
||||||
|
automationId: '00000000-0000-4000-8000-00000000046c',
|
||||||
|
});
|
||||||
|
const identityDecisionId = '019b0000-0000-7000-8000-000000000469';
|
||||||
|
const identityReview = automationDecisionReviewFile(
|
||||||
|
identity,
|
||||||
|
identityDecisionId,
|
||||||
|
'adopt',
|
||||||
|
'reviewed_lossless',
|
||||||
|
'identity-reject',
|
||||||
|
);
|
||||||
|
const identityPrepareCommand = automationDecisionPrepareCommand(
|
||||||
|
identity,
|
||||||
|
identityDecisionId,
|
||||||
|
);
|
||||||
|
const identityPrepared = await prepareLocalReconciliationAutomationDecision(
|
||||||
|
identityPrepareCommand,
|
||||||
|
);
|
||||||
|
for (const auth of [
|
||||||
|
{ reviewerId: 'another-owner', assurance: 'local_console' },
|
||||||
|
{ reviewerId: 'review-owner', assurance: 'password' },
|
||||||
|
]) {
|
||||||
|
const rejected = automationDecisionCommitFixture(
|
||||||
|
identity,
|
||||||
|
{
|
||||||
|
result: identityPrepared,
|
||||||
|
commandOptions: identityPrepareCommand.options,
|
||||||
|
},
|
||||||
|
identityReview.filePath,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
commitLocalReconciliationAutomationDecision(
|
||||||
|
rejected.command,
|
||||||
|
rejected.dependencies,
|
||||||
|
),
|
||||||
|
/requires the same recently strong authenticated User/,
|
||||||
|
);
|
||||||
|
assert.equal(rejected.authenticationCount(), 1);
|
||||||
|
assert.equal(rejected.confirmationCount(), 0);
|
||||||
|
assert.equal(rejected.databaseCloseCount(), 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automation decision replays every publication boundary without repeated authentication', async (t) => {
|
||||||
|
const prepareState = await plannedAutomationFixture(t, {
|
||||||
|
suffix: 'prepare-response-loss',
|
||||||
|
automationId: '00000000-0000-4000-8000-00000000046d',
|
||||||
|
});
|
||||||
|
const prepareDecisionId = '019b0000-0000-7000-8000-00000000046d';
|
||||||
|
const prepareCommand = automationDecisionPrepareCommand(
|
||||||
|
prepareState,
|
||||||
|
prepareDecisionId,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
prepareLocalReconciliationAutomationDecision(prepareCommand, {
|
||||||
|
afterHeadPrepared() {
|
||||||
|
throw new Error('automation decision prepare response loss');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/automation decision prepare response loss/,
|
||||||
|
);
|
||||||
|
const prepareReplay = await prepareLocalReconciliationAutomationDecision(
|
||||||
|
prepareCommand,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
prepareReplay.state,
|
||||||
|
'reconciliation_automation_decision_prepared',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [window, tail] of [
|
||||||
|
['authorization', '471'],
|
||||||
|
['receipt', '472'],
|
||||||
|
['seal', '473'],
|
||||||
|
['head', '474'],
|
||||||
|
]) {
|
||||||
|
const state = await plannedAutomationFixture(t, {
|
||||||
|
suffix: `${window}-response-loss`,
|
||||||
|
automationId: `00000000-0000-4000-8000-000000000${tail}`,
|
||||||
|
});
|
||||||
|
const decisionId = `019b0000-0000-7000-8000-000000000${tail}`;
|
||||||
|
const review = automationDecisionReviewFile(
|
||||||
|
state,
|
||||||
|
decisionId,
|
||||||
|
'adopt',
|
||||||
|
'reviewed_lossless',
|
||||||
|
`${window}-response-loss`,
|
||||||
|
);
|
||||||
|
const selectedPrepareCommand = automationDecisionPrepareCommand(
|
||||||
|
state,
|
||||||
|
decisionId,
|
||||||
|
);
|
||||||
|
const prepared = await prepareLocalReconciliationAutomationDecision(
|
||||||
|
selectedPrepareCommand,
|
||||||
|
);
|
||||||
|
const commit = automationDecisionCommitFixture(
|
||||||
|
state,
|
||||||
|
{ result: prepared, commandOptions: selectedPrepareCommand.options },
|
||||||
|
review.filePath,
|
||||||
|
);
|
||||||
|
const callback =
|
||||||
|
window === 'authorization'
|
||||||
|
? 'afterAuthorizationPublished'
|
||||||
|
: window === 'receipt'
|
||||||
|
? 'afterReceiptPublished'
|
||||||
|
: window === 'seal'
|
||||||
|
? 'afterTerminalSealed'
|
||||||
|
: 'afterHeadAdvanced';
|
||||||
|
await assert.rejects(
|
||||||
|
commitLocalReconciliationAutomationDecision(commit.command, {
|
||||||
|
...commit.dependencies,
|
||||||
|
[callback]() {
|
||||||
|
throw new Error(`automation decision ${window} response loss`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new RegExp(`automation decision ${window} response loss`),
|
||||||
|
);
|
||||||
|
const replay = await commitLocalReconciliationAutomationDecision(
|
||||||
|
commit.command,
|
||||||
|
commit.dependencies,
|
||||||
|
);
|
||||||
|
assert.equal(replay.state, 'reconciliation_automation_reviewed');
|
||||||
|
assert.equal(commit.authenticationCount(), 1);
|
||||||
|
assert.equal(commit.confirmationCount(), 1);
|
||||||
|
assert.equal(commit.databaseCloseCount(), 1);
|
||||||
|
if (window === 'head') assert.equal(replay.status, 'existing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automation decision verification rejects sealed authorization and plan drift', async (t) => {
|
||||||
|
const authorizationState = await plannedAutomationFixture(t, {
|
||||||
|
suffix: 'authorization-drift',
|
||||||
|
automationId: '00000000-0000-4000-8000-000000000475',
|
||||||
|
});
|
||||||
|
const authorizationDecisionId =
|
||||||
|
'019b0000-0000-7000-8000-000000000475';
|
||||||
|
const authorizationReview = automationDecisionReviewFile(
|
||||||
|
authorizationState,
|
||||||
|
authorizationDecisionId,
|
||||||
|
'adopt',
|
||||||
|
'reviewed_lossless',
|
||||||
|
'authorization-drift',
|
||||||
|
);
|
||||||
|
const authorizationPrepareCommand = automationDecisionPrepareCommand(
|
||||||
|
authorizationState,
|
||||||
|
authorizationDecisionId,
|
||||||
|
);
|
||||||
|
const authorizationPrepared =
|
||||||
|
await prepareLocalReconciliationAutomationDecision(
|
||||||
|
authorizationPrepareCommand,
|
||||||
|
);
|
||||||
|
const authorizationCommit = automationDecisionCommitFixture(
|
||||||
|
authorizationState,
|
||||||
|
{
|
||||||
|
result: authorizationPrepared,
|
||||||
|
commandOptions: authorizationPrepareCommand.options,
|
||||||
|
},
|
||||||
|
authorizationReview.filePath,
|
||||||
|
);
|
||||||
|
const committed = await commitLocalReconciliationAutomationDecision(
|
||||||
|
authorizationCommit.command,
|
||||||
|
authorizationCommit.dependencies,
|
||||||
|
);
|
||||||
|
const decisionDirectory = path.join(
|
||||||
|
authorizationState.automationDecisionRoot,
|
||||||
|
authorizationState.automationCommand.request.automationId,
|
||||||
|
);
|
||||||
|
const authorizationPath = path.join(
|
||||||
|
decisionDirectory,
|
||||||
|
'authorization.ndjson',
|
||||||
|
);
|
||||||
|
fs.chmodSync(decisionDirectory, 0o700);
|
||||||
|
fs.chmodSync(authorizationPath, 0o600);
|
||||||
|
fs.appendFileSync(authorizationPath, '{}\n');
|
||||||
|
fs.chmodSync(authorizationPath, 0o400);
|
||||||
|
fs.chmodSync(decisionDirectory, 0o500);
|
||||||
|
await assert.rejects(
|
||||||
|
verifyLocalReconciliationAutomationDecision({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.decision.verify',
|
||||||
|
options: authorizationPrepareCommand.options,
|
||||||
|
request: {
|
||||||
|
decisionId: authorizationDecisionId,
|
||||||
|
automationId: authorizationState.automationCommand.request.automationId,
|
||||||
|
expectedDecisionDigest: committed.decisionDigest,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(error) => {
|
||||||
|
const messages = [];
|
||||||
|
for (let current = error; current; current = current.cause) {
|
||||||
|
messages.push(String(current.message));
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
messages.join('\n'),
|
||||||
|
/authorization verification failed|authorization file/,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const planState = await plannedAutomationFixture(t, {
|
||||||
|
suffix: 'plan-drift-decision',
|
||||||
|
automationId: '00000000-0000-4000-8000-000000000476',
|
||||||
|
});
|
||||||
|
const planDecisionId = '019b0000-0000-7000-8000-000000000476';
|
||||||
|
const planReview = automationDecisionReviewFile(
|
||||||
|
planState,
|
||||||
|
planDecisionId,
|
||||||
|
'adopt',
|
||||||
|
'reviewed_lossless',
|
||||||
|
'plan-drift-decision',
|
||||||
|
);
|
||||||
|
const planPrepareCommand = automationDecisionPrepareCommand(
|
||||||
|
planState,
|
||||||
|
planDecisionId,
|
||||||
|
);
|
||||||
|
const planPrepared = await prepareLocalReconciliationAutomationDecision(
|
||||||
|
planPrepareCommand,
|
||||||
|
);
|
||||||
|
const planPath = path.join(planState.automationDirectory, 'plan.ndjson');
|
||||||
|
fs.chmodSync(planState.automationDirectory, 0o700);
|
||||||
|
fs.chmodSync(planPath, 0o600);
|
||||||
|
fs.appendFileSync(planPath, '{}\n');
|
||||||
|
fs.chmodSync(planPath, 0o400);
|
||||||
|
fs.chmodSync(planState.automationDirectory, 0o500);
|
||||||
|
const planCommit = automationDecisionCommitFixture(
|
||||||
|
planState,
|
||||||
|
{ result: planPrepared, commandOptions: planPrepareCommand.options },
|
||||||
|
planReview.filePath,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
commitLocalReconciliationAutomationDecision(
|
||||||
|
planCommit.command,
|
||||||
|
planCommit.dependencies,
|
||||||
|
),
|
||||||
|
(error) => {
|
||||||
|
const messages = [];
|
||||||
|
for (let current = error; current; current = current.cause) {
|
||||||
|
messages.push(String(current.message));
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
messages.join('\n'),
|
||||||
|
/plan file identity is invalid|plan file content drifted/,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(planCommit.authenticationCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('automation row plan fails closed to manual review on a target task collision', async (t) => {
|
test('automation row plan fails closed to manual review on a target task collision', async (t) => {
|
||||||
const state = await reviewedApplicationFixture(t, {
|
const state = await reviewedApplicationFixture(t, {
|
||||||
planId: '00000000-0000-4000-8000-000000000435',
|
planId: '00000000-0000-4000-8000-000000000435',
|
||||||
|
|||||||
@@ -2018,6 +2018,21 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
'@qinglong/local-sqlite/authentication-read',
|
'@qinglong/local-sqlite/authentication-read',
|
||||||
].includes(specifier)
|
].includes(specifier)
|
||||||
) &&
|
) &&
|
||||||
|
!(
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/deployment/reconciliation/application/automation/decisionCoordinator.ts' &&
|
||||||
|
[
|
||||||
|
'@qinglong/local-admin/reconciliation-automation-decision',
|
||||||
|
'@qinglong/local-owner-console/authenticated-command',
|
||||||
|
'@qinglong/local-sqlite/authentication-read',
|
||||||
|
].includes(specifier)
|
||||||
|
) &&
|
||||||
|
!(
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/deployment/reconciliation/application/automation/planReader.ts' &&
|
||||||
|
specifier ===
|
||||||
|
'@qinglong/local-admin/reconciliation-automation-decision'
|
||||||
|
) &&
|
||||||
!(
|
!(
|
||||||
path.relative(packageDirectory, filePath) ===
|
path.relative(packageDirectory, filePath) ===
|
||||||
'src/deployment/reconciliation/application/automation/rowPlan.ts' &&
|
'src/deployment/reconciliation/application/automation/rowPlan.ts' &&
|
||||||
@@ -2195,6 +2210,12 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
(path.relative(packageDirectory, filePath) ===
|
(path.relative(packageDirectory, filePath) ===
|
||||||
'src/legacy-adoption/legacyCrontabDecisionIssuerKeyring.ts' &&
|
'src/legacy-adoption/legacyCrontabDecisionIssuerKeyring.ts' &&
|
||||||
specifier === '@qinglong/runtime-core/local-secret') ||
|
specifier === '@qinglong/runtime-core/local-secret') ||
|
||||||
|
(path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/legacy-adoption/reconciliationAutomationDecision.ts' &&
|
||||||
|
[
|
||||||
|
'@qinglong/runtime-core/local-secret',
|
||||||
|
'@qinglong/runtime-core/security',
|
||||||
|
].includes(specifier)) ||
|
||||||
(path.relative(packageDirectory, filePath) ===
|
(path.relative(packageDirectory, filePath) ===
|
||||||
'src/plugin-package/pluginPackageStaging.ts' &&
|
'src/plugin-package/pluginPackageStaging.ts' &&
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user