mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(local): authorize reconciliation automation decisions
This commit is contained in:
@@ -32,6 +32,8 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_application_prepared'
|
||||
| 'reconciliation_application_planned'
|
||||
| 'reconciliation_automation_planned'
|
||||
| 'reconciliation_automation_decision_prepared'
|
||||
| 'reconciliation_automation_reviewed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -172,6 +174,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_application_prepared' &&
|
||||
head.state !== 'reconciliation_application_planned' &&
|
||||
head.state !== 'reconciliation_automation_planned' &&
|
||||
head.state !== 'reconciliation_automation_decision_prepared' &&
|
||||
head.state !== 'reconciliation_automation_reviewed' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
@@ -347,6 +351,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_application_prepared'
|
||||
| 'reconciliation_application_planned'
|
||||
| 'reconciliation_automation_planned'
|
||||
| 'reconciliation_automation_decision_prepared'
|
||||
| 'reconciliation_automation_reviewed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -394,6 +400,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_application_prepared' ||
|
||||
current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'reconciliation_automation_planned' ||
|
||||
current.state === 'reconciliation_automation_decision_prepared' ||
|
||||
current.state === 'reconciliation_automation_reviewed' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running' ||
|
||||
current.state === 'legacy_ready')
|
||||
@@ -427,6 +435,10 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_application_prepared') ||
|
||||
(state === 'reconciliation_automation_planned' &&
|
||||
current.state === 'reconciliation_application_planned') ||
|
||||
(state === 'reconciliation_automation_decision_prepared' &&
|
||||
current.state === 'reconciliation_automation_planned') ||
|
||||
(state === 'reconciliation_automation_reviewed' &&
|
||||
current.state === 'reconciliation_automation_decision_prepared') ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
|
||||
@@ -137,6 +137,14 @@ import {
|
||||
verifyLocalReconciliationAutomationPlan,
|
||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||
} from './reconciliation/application/automation/coordinator';
|
||||
import {
|
||||
commitLocalReconciliationAutomationDecision,
|
||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||
prepareLocalReconciliationAutomationDecision,
|
||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||
verifyLocalReconciliationAutomationDecision,
|
||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||
} from './reconciliation/application/automation/decisionCoordinator';
|
||||
|
||||
export {
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -163,6 +171,12 @@ export {
|
||||
planLocalReconciliationAutomationCommandFile,
|
||||
verifyLocalReconciliationAutomationPlan,
|
||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||
prepareLocalReconciliationAutomationDecision,
|
||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||
commitLocalReconciliationAutomationDecision,
|
||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||
verifyLocalReconciliationAutomationDecision,
|
||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -203,6 +217,21 @@ export {
|
||||
export {
|
||||
type LocalReconciliationAutomationPlanDependencies,
|
||||
} from './reconciliation/application/automation/coordinator';
|
||||
export {
|
||||
normalizeLocalReconciliationAutomationDecisionCommitCommand,
|
||||
normalizeLocalReconciliationAutomationDecisionPrepareCommand,
|
||||
normalizeLocalReconciliationAutomationDecisionVerifyCommand,
|
||||
type LocalReconciliationAutomationDecisionCommitCommand,
|
||||
type LocalReconciliationAutomationDecisionCommitOptions,
|
||||
type LocalReconciliationAutomationDecisionOptions,
|
||||
type LocalReconciliationAutomationDecisionPrepareCommand,
|
||||
type LocalReconciliationAutomationDecisionPrepareResult,
|
||||
type LocalReconciliationAutomationDecisionTerminalResult,
|
||||
type LocalReconciliationAutomationDecisionVerifyCommand,
|
||||
} from './reconciliation/application/automation/decisionContract';
|
||||
export {
|
||||
type LocalReconciliationAutomationDecisionDependencies,
|
||||
} from './reconciliation/application/automation/decisionCoordinator';
|
||||
export {
|
||||
MAX_EDGE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||
MAX_STANDALONE_LOCAL_RECONCILIATION_AUTOMATION_PLAN_BYTES,
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
verifyLocalReconciliationApplicationCommandFile,
|
||||
planLocalReconciliationAutomationCommandFile,
|
||||
verifyLocalReconciliationAutomationPlanCommandFile,
|
||||
prepareLocalReconciliationAutomationDecisionCommandFile,
|
||||
commitLocalReconciliationAutomationDecisionCommandFile,
|
||||
verifyLocalReconciliationAutomationDecisionCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
@@ -42,7 +45,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|reconciliation-capture-prepare|reconciliation-capture-commit|reconciliation-capture-verify|reconciliation-plan-prepare|reconciliation-plan-commit|reconciliation-plan-verify|reconciliation-review-prepare|reconciliation-review-diagnostics|reconciliation-review-commit|reconciliation-review-verify|reconciliation-application-prepare|reconciliation-application-commit|reconciliation-application-verify|reconciliation-automation-plan|reconciliation-automation-verify|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -86,6 +89,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-application-verify' &&
|
||||
argv[0] !== 'reconciliation-automation-plan' &&
|
||||
argv[0] !== 'reconciliation-automation-verify' &&
|
||||
argv[0] !== 'reconciliation-automation-decision-prepare' &&
|
||||
argv[0] !== 'reconciliation-automation-decision-commit' &&
|
||||
argv[0] !== 'reconciliation-automation-decision-verify' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
argv[0] !== 'compose-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
@@ -186,6 +192,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? planLocalReconciliationAutomationCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-verify'
|
||||
? verifyLocalReconciliationAutomationPlanCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-decision-prepare'
|
||||
? prepareLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-decision-commit'
|
||||
? commitLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-decision-verify'
|
||||
? verifyLocalReconciliationAutomationDecisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-revision'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-preflight'
|
||||
|
||||
+27
@@ -609,6 +609,33 @@ function validateTerminalBinding(
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationTerminal {
|
||||
readonly receipt: Readonly<LocalReconciliationAutomationPlanReceipt>;
|
||||
readonly planPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-opens the sealed row plan for the separately authenticated decision
|
||||
* phase. This reader exposes no live database authority and never repairs a
|
||||
* terminal bundle.
|
||||
*/
|
||||
export function readLocalReconciliationAutomationTerminal(
|
||||
automationRoot: string,
|
||||
automationId: string,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationAutomationTerminal> {
|
||||
const selected = automationPaths(automationRoot, automationId);
|
||||
validateDirectory(selected.root, uid, [0o500], 'automation plan root');
|
||||
validateDirectory(selected.staging, uid, [0o500], 'automation staging');
|
||||
validateCatalog(selected, true);
|
||||
const receipt = readReceipt(selected.receipt, uid, [0o400]);
|
||||
if (receipt.automationId !== automationId) {
|
||||
configurationError('automation terminal identity drifted');
|
||||
}
|
||||
validatePlanFile(selected.plan, receipt, uid, [0o400]);
|
||||
return Object.freeze({ receipt, planPath: selected.plan });
|
||||
}
|
||||
|
||||
export async function planLocalReconciliationAutomation(
|
||||
value: unknown,
|
||||
dependencies: LocalReconciliationAutomationPlanDependencies = {},
|
||||
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const UUID_V7_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
export const MAX_LOCAL_RECONCILIATION_AUTOMATION_DECISION_LIFETIME_MS =
|
||||
30 * 60 * 1_000;
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly automationRoot: string;
|
||||
readonly automationDecisionRoot: string;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionPrepareCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.automation.decision.prepare';
|
||||
readonly options: Readonly<LocalReconciliationAutomationDecisionOptions>;
|
||||
readonly request: Readonly<{
|
||||
decisionId: string;
|
||||
automationId: string;
|
||||
expectedAutomationPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
preparedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionCommitOptions
|
||||
extends LocalReconciliationAutomationDecisionOptions {
|
||||
readonly targetDatabasePath: string;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionCommitCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.automation.decision.commit';
|
||||
readonly options: Readonly<LocalReconciliationAutomationDecisionCommitOptions>;
|
||||
readonly request: Readonly<{
|
||||
decisionId: string;
|
||||
automationId: string;
|
||||
expectedPreparationDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
decisionFilePath: string;
|
||||
committedAtMs: number;
|
||||
authorizationLifetimeMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.automation.decision.verify';
|
||||
readonly options: Readonly<LocalReconciliationAutomationDecisionOptions>;
|
||||
readonly request: Readonly<{
|
||||
decisionId: string;
|
||||
automationId: string;
|
||||
expectedDecisionDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionPrepareResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.automation.decision.prepare';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'reconciliation_automation_decision_prepared';
|
||||
readonly decisionId: string;
|
||||
readonly automationId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionTerminalResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| 'local.deployment.reconciliation.automation.decision.commit'
|
||||
| 'local.deployment.reconciliation.automation.decision.verify';
|
||||
readonly status: 'prepared' | 'existing' | 'verified';
|
||||
readonly state: 'reconciliation_automation_reviewed';
|
||||
readonly decisionId: string;
|
||||
readonly automationId: string;
|
||||
readonly decisionDigest: string;
|
||||
readonly signedDecisionSetDigest: string;
|
||||
readonly rowCount: number;
|
||||
readonly adoptedCount: number;
|
||||
readonly skippedCount: number;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation automation decision ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
value.includes('//') ||
|
||||
!SAFE_PATH_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
configurationError(`${label} must be a safe non-root absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function overlaps(left: string, right: string): boolean {
|
||||
const relative = path.relative(left, right);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
configurationError(`${label} must be below deploymentRoot`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
configurationError(`${label} must be a SHA-256 digest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function automationId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||
configurationError('automationId must be a lowercase UUID v4');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decisionId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UUID_V7_PATTERN.test(value)) {
|
||||
configurationError('decisionId must be a lowercase UUID v7');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function baseOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationAutomationDecisionOptions> {
|
||||
const options = object(value, 'options');
|
||||
const keys = [
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'automationDecisionRoot',
|
||||
'automationRoot',
|
||||
'deploymentRoot',
|
||||
];
|
||||
exact(options, keys, 'options');
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
configurationError('command identity is invalid');
|
||||
}
|
||||
const roots = keys
|
||||
.filter((key) => key !== 'allowRootService')
|
||||
.map((key) => safePath(options[key], key));
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
if (
|
||||
overlaps(roots[left]!, roots[right]!) ||
|
||||
overlaps(roots[right]!, roots[left]!)
|
||||
) {
|
||||
configurationError('authority roots overlap');
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
deploymentRoot: safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||
applicationRoot: safePath(options.applicationRoot, 'applicationRoot'),
|
||||
automationRoot: safePath(options.automationRoot, 'automationRoot'),
|
||||
automationDecisionRoot: safePath(
|
||||
options.automationDecisionRoot,
|
||||
'automationDecisionRoot',
|
||||
),
|
||||
allowRootService: options.allowRootService as boolean,
|
||||
});
|
||||
}
|
||||
|
||||
function command(value: unknown, operation: string) {
|
||||
const selected = object(value, 'command');
|
||||
exact(selected, ['operation', 'options', 'request', 'schemaVersion'], 'command');
|
||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||
configurationError('command version or operation is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: selected.options,
|
||||
request: object(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationAutomationDecisionPrepareCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationAutomationDecisionPrepareCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.automation.decision.prepare',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'automationId',
|
||||
'decisionId',
|
||||
'expectedAutomationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
'preparedAtMs',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.preparedAtMs) ||
|
||||
(selected.request.preparedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('preparedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.automation.decision.prepare',
|
||||
options: baseOptions(selected.options),
|
||||
request: Object.freeze({
|
||||
decisionId: decisionId(selected.request.decisionId),
|
||||
automationId: automationId(selected.request.automationId),
|
||||
expectedAutomationPlanDigest: digest(
|
||||
selected.request.expectedAutomationPlanDigest,
|
||||
'expectedAutomationPlanDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
preparedAtMs: selected.request.preparedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationAutomationDecisionCommitCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationAutomationDecisionCommitCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.automation.decision.commit',
|
||||
);
|
||||
const options = object(selected.options, 'options');
|
||||
const hasBusyTimeout = Object.hasOwn(options, 'busyTimeoutMs');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'automationDecisionRoot',
|
||||
'automationRoot',
|
||||
'credentialFilePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'targetDatabasePath',
|
||||
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const base = baseOptions({
|
||||
allowRootService: options.allowRootService,
|
||||
applicationRoot: options.applicationRoot,
|
||||
automationDecisionRoot: options.automationDecisionRoot,
|
||||
automationRoot: options.automationRoot,
|
||||
deploymentRoot: options.deploymentRoot,
|
||||
});
|
||||
const targetDatabasePath = safePath(
|
||||
options.targetDatabasePath,
|
||||
'targetDatabasePath',
|
||||
);
|
||||
const ownerPepperKeyringDirectory = safePath(
|
||||
options.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const credentialFilePath = safePath(
|
||||
options.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
for (const [candidate, label] of [
|
||||
[ownerPepperKeyringDirectory, 'ownerPepperKeyringDirectory'],
|
||||
[credentialFilePath, 'credentialFilePath'],
|
||||
] as const) {
|
||||
descendant(base.deploymentRoot, candidate, label);
|
||||
}
|
||||
if (
|
||||
options.busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(options.busyTimeoutMs) ||
|
||||
(options.busyTimeoutMs as number) < 1 ||
|
||||
(options.busyTimeoutMs as number) > 60_000)
|
||||
) {
|
||||
configurationError('busyTimeoutMs is invalid');
|
||||
}
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'authorizationLifetimeMs',
|
||||
'automationId',
|
||||
'committedAtMs',
|
||||
'decisionFilePath',
|
||||
'decisionId',
|
||||
'expectedHeadDigest',
|
||||
'expectedPreparationDigest',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const decisionFilePath = safePath(
|
||||
selected.request.decisionFilePath,
|
||||
'decisionFilePath',
|
||||
);
|
||||
if (
|
||||
[
|
||||
base.deploymentRoot,
|
||||
base.applicationRoot,
|
||||
base.automationRoot,
|
||||
base.automationDecisionRoot,
|
||||
].some(
|
||||
(root) =>
|
||||
overlaps(root, decisionFilePath) || overlaps(decisionFilePath, root),
|
||||
)
|
||||
) {
|
||||
configurationError('decisionFilePath overlaps an authority root');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.committedAtMs) ||
|
||||
(selected.request.committedAtMs as number) < 0 ||
|
||||
!Number.isSafeInteger(selected.request.authorizationLifetimeMs) ||
|
||||
(selected.request.authorizationLifetimeMs as number) < 1 ||
|
||||
(selected.request.authorizationLifetimeMs as number) >
|
||||
MAX_LOCAL_RECONCILIATION_AUTOMATION_DECISION_LIFETIME_MS
|
||||
) {
|
||||
configurationError('decision timestamp or lifetime is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.automation.decision.commit',
|
||||
options: Object.freeze({
|
||||
...base,
|
||||
targetDatabasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
...(options.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: options.busyTimeoutMs as number }),
|
||||
}),
|
||||
request: Object.freeze({
|
||||
decisionId: decisionId(selected.request.decisionId),
|
||||
automationId: automationId(selected.request.automationId),
|
||||
expectedPreparationDigest: digest(
|
||||
selected.request.expectedPreparationDigest,
|
||||
'expectedPreparationDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
decisionFilePath,
|
||||
committedAtMs: selected.request.committedAtMs as number,
|
||||
authorizationLifetimeMs:
|
||||
selected.request.authorizationLifetimeMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationAutomationDecisionVerifyCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationAutomationDecisionVerifyCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.automation.decision.verify',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
['automationId', 'decisionId', 'expectedDecisionDigest'],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.automation.decision.verify',
|
||||
options: baseOptions(selected.options),
|
||||
request: Object.freeze({
|
||||
decisionId: decisionId(selected.request.decisionId),
|
||||
automationId: automationId(selected.request.automationId),
|
||||
expectedDecisionDigest: digest(
|
||||
selected.request.expectedDecisionDigest,
|
||||
'expectedDecisionDigest',
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1042
File diff suppressed because it is too large
Load Diff
+239
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
normalizeLocalReconciliationAutomationDecisionPrepareCommand,
|
||||
type LocalReconciliationAutomationDecisionPrepareCommand,
|
||||
} from './decisionContract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||
|
||||
const INTENT_SCHEMA =
|
||||
'qinglong3-local-reconciliation-automation-decision-intent';
|
||||
const RECEIPT_SCHEMA =
|
||||
'qinglong3-local-reconciliation-automation-decision-receipt';
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionIntent {
|
||||
readonly schema: typeof INTENT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly command: Readonly<LocalReconciliationAutomationDecisionPrepareCommand>;
|
||||
readonly applicationId: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly legacyInventoryDigest: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly projectId: string;
|
||||
readonly legacyTimezone: string | null;
|
||||
readonly instanceId: string;
|
||||
readonly cutoverId: string;
|
||||
readonly activationDigest: string;
|
||||
readonly generation: number;
|
||||
readonly preparationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationAutomationDecisionReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_automation_reviewed';
|
||||
readonly decisionId: string;
|
||||
readonly automationId: string;
|
||||
readonly automationPlanDigest: string;
|
||||
readonly legacyInventoryDigest: string;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly authorizationFileDigest: string;
|
||||
readonly signedReceiptDigest: string;
|
||||
readonly signedDecisionSetDigest: string;
|
||||
readonly reviewFileDigest: string;
|
||||
readonly reviewerDigest: string;
|
||||
readonly rowCount: number;
|
||||
readonly adoptedCount: number;
|
||||
readonly skippedCount: number;
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly decisionDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation automation decision evidence ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationAutomationDecisionIntent(
|
||||
input: Omit<LocalReconciliationAutomationDecisionIntent, 'schema' | 'schemaVersion' | 'preparationDigest'>,
|
||||
): Readonly<LocalReconciliationAutomationDecisionIntent> {
|
||||
const payload = Object.freeze({
|
||||
schema: INTENT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
...input,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
preparationDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationAutomationDecisionIntent(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationAutomationDecisionIntent> {
|
||||
const intent = exact(
|
||||
value,
|
||||
[
|
||||
'activationDigest',
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'command',
|
||||
'cutoverId',
|
||||
'generation',
|
||||
'instanceId',
|
||||
'legacyInventoryDigest',
|
||||
'legacyTimezone',
|
||||
'preparationDigest',
|
||||
'profile',
|
||||
'projectId',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
],
|
||||
'intent',
|
||||
);
|
||||
const { preparationDigest, ...payload } = intent;
|
||||
const normalizedCommand =
|
||||
normalizeLocalReconciliationAutomationDecisionPrepareCommand(
|
||||
intent.command,
|
||||
);
|
||||
const normalizedPayload = Object.freeze({
|
||||
...payload,
|
||||
command: normalizedCommand,
|
||||
});
|
||||
if (
|
||||
intent.schema !== INTENT_SCHEMA ||
|
||||
intent.schemaVersion !== 1 ||
|
||||
typeof preparationDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(preparationDigest) ||
|
||||
cutoverDigest(payload) !== preparationDigest ||
|
||||
cutoverDigest(normalizedPayload) !== preparationDigest ||
|
||||
(intent.profile !== 'edge' && intent.profile !== 'standalone') ||
|
||||
![intent.applicationPlanDigest, intent.legacyInventoryDigest, intent.activationDigest].every(
|
||||
(digest) => typeof digest === 'string' && DIGEST_PATTERN.test(digest),
|
||||
) ||
|
||||
typeof intent.applicationId !== 'string' ||
|
||||
typeof intent.instanceId !== 'string' ||
|
||||
typeof intent.cutoverId !== 'string' ||
|
||||
typeof intent.projectId !== 'string' ||
|
||||
(intent.legacyTimezone !== null &&
|
||||
typeof intent.legacyTimezone !== 'string') ||
|
||||
!Number.isSafeInteger(intent.generation) ||
|
||||
(intent.generation as number) < 1
|
||||
) {
|
||||
configurationError('intent binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...normalizedPayload,
|
||||
preparationDigest,
|
||||
}) as unknown as Readonly<LocalReconciliationAutomationDecisionIntent>;
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationAutomationDecisionReceipt(
|
||||
input: Omit<LocalReconciliationAutomationDecisionReceipt, 'schema' | 'schemaVersion' | 'state' | 'decisionDigest'>,
|
||||
): Readonly<LocalReconciliationAutomationDecisionReceipt> {
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_automation_reviewed' as const,
|
||||
...input,
|
||||
});
|
||||
return Object.freeze({
|
||||
...payload,
|
||||
decisionDigest: cutoverDigest(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationAutomationDecisionReceipt(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationAutomationDecisionReceipt> {
|
||||
const receipt = exact(
|
||||
value,
|
||||
[
|
||||
'adoptedCount',
|
||||
'authorizationFileDigest',
|
||||
'automationId',
|
||||
'automationPlanDigest',
|
||||
'decisionDigest',
|
||||
'decisionId',
|
||||
'expiresAtMs',
|
||||
'issuedAtMs',
|
||||
'legacyInventoryDigest',
|
||||
'preparedHeadDigest',
|
||||
'reviewFileDigest',
|
||||
'reviewerDigest',
|
||||
'rowCount',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'signedDecisionSetDigest',
|
||||
'signedReceiptDigest',
|
||||
'skippedCount',
|
||||
'state',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
const { decisionDigest, ...payload } = receipt;
|
||||
if (
|
||||
receipt.schema !== RECEIPT_SCHEMA ||
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.state !== 'reconciliation_automation_reviewed' ||
|
||||
typeof decisionDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(decisionDigest) ||
|
||||
cutoverDigest(payload) !== decisionDigest ||
|
||||
![
|
||||
receipt.automationPlanDigest,
|
||||
receipt.legacyInventoryDigest,
|
||||
receipt.preparedHeadDigest,
|
||||
receipt.authorizationFileDigest,
|
||||
receipt.signedReceiptDigest,
|
||||
receipt.signedDecisionSetDigest,
|
||||
receipt.reviewFileDigest,
|
||||
receipt.reviewerDigest,
|
||||
].every(
|
||||
(digest) => typeof digest === 'string' && DIGEST_PATTERN.test(digest),
|
||||
) ||
|
||||
![receipt.rowCount, receipt.adoptedCount, receipt.skippedCount].every(
|
||||
(count) => Number.isSafeInteger(count) && (count as number) >= 0,
|
||||
) ||
|
||||
(receipt.adoptedCount as number) + (receipt.skippedCount as number) !==
|
||||
receipt.rowCount ||
|
||||
!Number.isSafeInteger(receipt.issuedAtMs) ||
|
||||
(receipt.issuedAtMs as number) < 0 ||
|
||||
!Number.isSafeInteger(receipt.expiresAtMs) ||
|
||||
(receipt.expiresAtMs as number) <= (receipt.issuedAtMs as number) ||
|
||||
typeof receipt.decisionId !== 'string' ||
|
||||
typeof receipt.automationId !== 'string'
|
||||
) {
|
||||
configurationError('receipt binding is invalid');
|
||||
}
|
||||
return Object.freeze(receipt) as unknown as Readonly<LocalReconciliationAutomationDecisionReceipt>;
|
||||
}
|
||||
|
||||
export function localReconciliationAutomationDecisionEvidenceContents(
|
||||
value:
|
||||
| Readonly<LocalReconciliationAutomationDecisionIntent>
|
||||
| Readonly<LocalReconciliationAutomationDecisionReceipt>,
|
||||
): string {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type { ReconciliationAutomationDecisionRequirement } from '@qinglong/local-admin/reconciliation-automation-decision';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import { cutoverDigest } from '../../../cutover/targetEvidence';
|
||||
import type {
|
||||
LocalReconciliationAutomationPlanHeader,
|
||||
LocalReconciliationAutomationPlanReceipt,
|
||||
LocalReconciliationAutomationPlanRow,
|
||||
} from './rowPlan';
|
||||
|
||||
const HEADER_KIND = 'qinglong3-local-reconciliation-automation-plan-header';
|
||||
const ROW_KIND = 'qinglong3-local-reconciliation-automation-plan-row';
|
||||
const FOOTER_KIND = 'qinglong3-local-reconciliation-automation-plan-footer';
|
||||
const READ_CHUNK_BYTES = 64 * 1024;
|
||||
const MAX_LINE_BYTES = 64 * 1024;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
interface FileLine {
|
||||
readonly value: Buffer;
|
||||
readonly framed: Buffer;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation automation plan reader ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
configurationError(`${label} must be an object`);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
configurationError(`${label} shape is invalid`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function parse(line: Buffer, label: string): unknown {
|
||||
if (line.length < 2 || line.length > MAX_LINE_BYTES) {
|
||||
configurationError(`${label} exceeds its line bound`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(line.toString('utf8')) as unknown;
|
||||
} catch (error) {
|
||||
return configurationError(`${label} is not JSON`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function* lines(descriptor: number, size: number): Iterable<FileLine> {
|
||||
let position = 0;
|
||||
let pending = Buffer.alloc(0);
|
||||
try {
|
||||
while (position < size) {
|
||||
const chunk = Buffer.allocUnsafe(
|
||||
Math.min(READ_CHUNK_BYTES, size - position),
|
||||
);
|
||||
const bytesRead = fs.readSync(
|
||||
descriptor,
|
||||
chunk,
|
||||
0,
|
||||
chunk.length,
|
||||
position,
|
||||
);
|
||||
if (bytesRead < 1) {
|
||||
chunk.fill(0);
|
||||
configurationError('file ended unexpectedly');
|
||||
}
|
||||
position += bytesRead;
|
||||
const material = pending.length
|
||||
? Buffer.concat([pending, chunk.subarray(0, bytesRead)])
|
||||
: Buffer.from(chunk.subarray(0, bytesRead));
|
||||
pending.fill(0);
|
||||
chunk.fill(0);
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
const newline = material.indexOf(0x0a, cursor);
|
||||
if (newline < 0) break;
|
||||
const framed = Buffer.from(material.subarray(cursor, newline + 1));
|
||||
const value = Buffer.from(material.subarray(cursor, newline));
|
||||
cursor = newline + 1;
|
||||
yield { value, framed };
|
||||
}
|
||||
pending = Buffer.from(material.subarray(cursor));
|
||||
material.fill(0);
|
||||
if (pending.length > MAX_LINE_BYTES) {
|
||||
configurationError('record exceeds its line bound');
|
||||
}
|
||||
}
|
||||
if (pending.length !== 0) configurationError('file is not newline framed');
|
||||
} finally {
|
||||
pending.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function header(
|
||||
value: unknown,
|
||||
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
|
||||
): Readonly<LocalReconciliationAutomationPlanHeader> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'automationId',
|
||||
'bundleDigest',
|
||||
'bundleFingerprintDigest',
|
||||
'headerDigest',
|
||||
'kind',
|
||||
'legacyTimezone',
|
||||
'preparedAtMs',
|
||||
'preparedHeadDigest',
|
||||
'profile',
|
||||
'projectId',
|
||||
'reviewAuthorizationDigest',
|
||||
'reviewDecisionFileDigest',
|
||||
'reviewDecisionSetDigest',
|
||||
'reviewDigest',
|
||||
'schemaVersion',
|
||||
'tableDisposition',
|
||||
],
|
||||
'header',
|
||||
);
|
||||
const { headerDigest, ...payload } = record;
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== HEADER_KIND ||
|
||||
typeof headerDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(headerDigest) ||
|
||||
cutoverDigest(payload) !== headerDigest ||
|
||||
record.automationId !== receipt.automationId ||
|
||||
record.applicationId !== receipt.applicationId ||
|
||||
record.applicationPlanDigest !== receipt.applicationPlanDigest ||
|
||||
record.preparedHeadDigest !== receipt.preparedHeadDigest ||
|
||||
record.preparedAtMs !== receipt.preparedAtMs ||
|
||||
(record.profile !== 'edge' && record.profile !== 'standalone') ||
|
||||
typeof record.projectId !== 'string' ||
|
||||
(record.legacyTimezone !== null &&
|
||||
typeof record.legacyTimezone !== 'string') ||
|
||||
(record.tableDisposition !== 'adopt_legacy' &&
|
||||
record.tableDisposition !== 'retain_both') ||
|
||||
![
|
||||
record.reviewDigest,
|
||||
record.reviewAuthorizationDigest,
|
||||
record.reviewDecisionSetDigest,
|
||||
record.reviewDecisionFileDigest,
|
||||
record.bundleDigest,
|
||||
record.bundleFingerprintDigest,
|
||||
].every(
|
||||
(digest) => typeof digest === 'string' && DIGEST_PATTERN.test(digest),
|
||||
)
|
||||
) {
|
||||
configurationError('header binding is invalid');
|
||||
}
|
||||
return Object.freeze(record) as unknown as Readonly<LocalReconciliationAutomationPlanHeader>;
|
||||
}
|
||||
|
||||
function planRow(
|
||||
value: unknown,
|
||||
expectedOrdinal: number,
|
||||
): Readonly<LocalReconciliationAutomationPlanRow> {
|
||||
const record = exact(
|
||||
value,
|
||||
[
|
||||
'candidateDigest',
|
||||
'classification',
|
||||
'enabled',
|
||||
'kind',
|
||||
'proposedTaskId',
|
||||
'reasons',
|
||||
'requirement',
|
||||
'rowOrdinal',
|
||||
'rowPlanDigest',
|
||||
'schemaVersion',
|
||||
'sourceDigest',
|
||||
'target',
|
||||
'triggerCount',
|
||||
],
|
||||
'row',
|
||||
);
|
||||
const { rowPlanDigest, ...payload } = record;
|
||||
const target = record.target;
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
record.kind !== ROW_KIND ||
|
||||
record.rowOrdinal !== expectedOrdinal ||
|
||||
typeof record.sourceDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.sourceDigest) ||
|
||||
![
|
||||
'lossless',
|
||||
'requires_shell_compatibility',
|
||||
'requires_manual_action',
|
||||
'malformed',
|
||||
].includes(record.classification as string) ||
|
||||
!Array.isArray(record.reasons) ||
|
||||
record.reasons.some((reason) => typeof reason !== 'string') ||
|
||||
(record.proposedTaskId !== null &&
|
||||
typeof record.proposedTaskId !== 'string') ||
|
||||
(record.enabled !== null && typeof record.enabled !== 'boolean') ||
|
||||
!Number.isSafeInteger(record.triggerCount) ||
|
||||
(record.triggerCount as number) < 0 ||
|
||||
(record.candidateDigest !== null &&
|
||||
(typeof record.candidateDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.candidateDigest))) ||
|
||||
!target ||
|
||||
typeof target !== 'object' ||
|
||||
Array.isArray(target) ||
|
||||
!['review_adopt', 'review_skip_conflict', 'manual_required'].includes(
|
||||
record.requirement as string,
|
||||
) ||
|
||||
typeof rowPlanDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(rowPlanDigest) ||
|
||||
cutoverDigest(payload) !== rowPlanDigest
|
||||
) {
|
||||
configurationError('row binding is invalid');
|
||||
}
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
if (
|
||||
(targetRecord.state === 'absent' &&
|
||||
Object.keys(targetRecord).sort().join('\0') !== 'state') ||
|
||||
(targetRecord.state === 'occupied' &&
|
||||
(Object.keys(targetRecord).sort().join('\0') !==
|
||||
['contentDigest', 'revision', 'state'].sort().join('\0') ||
|
||||
!Number.isSafeInteger(targetRecord.revision) ||
|
||||
(targetRecord.revision as number) < 1 ||
|
||||
typeof targetRecord.contentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(targetRecord.contentDigest))) ||
|
||||
(targetRecord.state !== 'absent' && targetRecord.state !== 'occupied') ||
|
||||
(record.requirement === 'review_adopt' &&
|
||||
targetRecord.state !== 'absent') ||
|
||||
(record.requirement === 'review_skip_conflict' &&
|
||||
targetRecord.state !== 'occupied') ||
|
||||
(record.requirement === 'manual_required' &&
|
||||
record.candidateDigest !== null)
|
||||
) {
|
||||
configurationError('row target requirement is invalid');
|
||||
}
|
||||
return Object.freeze(record) as unknown as Readonly<LocalReconciliationAutomationPlanRow>;
|
||||
}
|
||||
|
||||
export function readLocalReconciliationAutomationPlanHeader(
|
||||
filePath: string,
|
||||
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationAutomationPlanHeader> {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o400 ||
|
||||
before.nlink !== 1n ||
|
||||
before.size !== BigInt(receipt.planFileBytes)
|
||||
) {
|
||||
configurationError('plan header file identity is invalid');
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs
|
||||
) {
|
||||
configurationError('plan header file changed while opening');
|
||||
}
|
||||
const records = lines(descriptor, Number(opened.size))[Symbol.iterator]();
|
||||
const selected = records.next();
|
||||
if (selected.done) configurationError('plan header is missing');
|
||||
try {
|
||||
return header(parse(selected.value.value, 'header'), receipt);
|
||||
} finally {
|
||||
selected.value.value.fill(0);
|
||||
selected.value.framed.fill(0);
|
||||
records.return?.();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('plan header cannot be read', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalReconciliationAutomationRequirementFactory(
|
||||
filePath: string,
|
||||
receipt: Readonly<LocalReconciliationAutomationPlanReceipt>,
|
||||
uid: number,
|
||||
): () => Iterable<ReconciliationAutomationDecisionRequirement> {
|
||||
return () =>
|
||||
(function* (): Iterable<ReconciliationAutomationDecisionRequirement> {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o400 ||
|
||||
before.nlink !== 1n ||
|
||||
before.size !== BigInt(receipt.planFileBytes)
|
||||
) {
|
||||
configurationError('plan file identity is invalid');
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs
|
||||
) {
|
||||
configurationError('plan file changed while opening');
|
||||
}
|
||||
const fileHash = createHash('sha256');
|
||||
const rowHash = createHash('sha256').update(
|
||||
'qinglong3.local-reconciliation-automation-row-set.v1\0',
|
||||
);
|
||||
let parsedHeader: Readonly<LocalReconciliationAutomationPlanHeader> | undefined;
|
||||
let rowCount = 0;
|
||||
let footerSeen = false;
|
||||
for (const selected of lines(descriptor, Number(opened.size))) {
|
||||
try {
|
||||
fileHash.update(selected.framed);
|
||||
const value = parse(selected.value, 'record');
|
||||
if (!parsedHeader) {
|
||||
parsedHeader = header(value, receipt);
|
||||
continue;
|
||||
}
|
||||
const kind = (value as { readonly kind?: unknown })?.kind;
|
||||
if (kind === ROW_KIND && !footerSeen) {
|
||||
const row = planRow(value, rowCount + 1);
|
||||
rowHash.update(selected.framed);
|
||||
rowCount += 1;
|
||||
yield Object.freeze({
|
||||
rowOrdinal: row.rowOrdinal,
|
||||
sourceDigest: row.sourceDigest,
|
||||
classification: row.classification,
|
||||
requirement: row.requirement,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const footer = exact(
|
||||
value,
|
||||
[
|
||||
'automationId',
|
||||
'automationPlanDigest',
|
||||
'conflictCount',
|
||||
'eligibleCount',
|
||||
'kind',
|
||||
'legacyInventoryDigest',
|
||||
'manualCount',
|
||||
'outcome',
|
||||
'rowCount',
|
||||
'rowSetDigest',
|
||||
'schemaVersion',
|
||||
'shellCompatibilityCount',
|
||||
'triggerCount',
|
||||
],
|
||||
'footer',
|
||||
);
|
||||
const { automationPlanDigest, ...footerPayload } = footer;
|
||||
if (
|
||||
footerSeen ||
|
||||
footer.schemaVersion !== 1 ||
|
||||
footer.kind !== FOOTER_KIND ||
|
||||
footer.automationId !== receipt.automationId ||
|
||||
footer.rowCount !== rowCount ||
|
||||
footer.rowCount !== receipt.rowCount ||
|
||||
footer.legacyInventoryDigest !== receipt.legacyInventoryDigest ||
|
||||
footer.rowSetDigest !== rowHash.digest('hex') ||
|
||||
footer.eligibleCount !== receipt.eligibleCount ||
|
||||
footer.manualCount !== receipt.manualCount ||
|
||||
footer.conflictCount !== receipt.conflictCount ||
|
||||
footer.shellCompatibilityCount !==
|
||||
receipt.shellCompatibilityCount ||
|
||||
footer.triggerCount !== receipt.triggerCount ||
|
||||
footer.outcome !== receipt.outcome ||
|
||||
automationPlanDigest !== receipt.automationPlanDigest ||
|
||||
cutoverDigest({
|
||||
headerDigest: parsedHeader.headerDigest,
|
||||
...footerPayload,
|
||||
}) !== automationPlanDigest
|
||||
) {
|
||||
configurationError('footer binding is invalid');
|
||||
}
|
||||
footerSeen = true;
|
||||
} finally {
|
||||
selected.value.fill(0);
|
||||
selected.framed.fill(0);
|
||||
}
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!parsedHeader ||
|
||||
!footerSeen ||
|
||||
rowCount !== receipt.rowCount ||
|
||||
fileHash.digest('hex') !== receipt.planFileDigest ||
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs
|
||||
) {
|
||||
configurationError('plan file content drifted');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
configurationError('plan file cannot be read', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -337,3 +337,64 @@ export function withLocalReconciliationSealedDatabase<T>(
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function withLocalReconciliationSealedDatabaseAsync<T>(
|
||||
bundle: Readonly<LocalReconciliationSealedBundle>,
|
||||
kind: LocalReconciliationSealedDatabaseKind,
|
||||
uid: number,
|
||||
dependencies: LocalReconciliationSealedBundleReaderDependencies,
|
||||
read: (client: DatabaseSync) => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
const current = inspectLocalReconciliationSealedBundle(
|
||||
bundle.captureRoot,
|
||||
bundle.receipt.captureId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.receipt.bundleDigest !== bundle.receipt.bundleDigest ||
|
||||
current.fingerprintDigest !== bundle.fingerprintDigest
|
||||
) {
|
||||
configurationError('sealed capture bundle drifted before SQLite open');
|
||||
}
|
||||
const selected = kind === 'target' ? current.target : current.legacy;
|
||||
if (selected.mode === 'manual_required') return null;
|
||||
const cacheKiB = current.manifest.profile === 'edge' ? 2_048 : 8_192;
|
||||
dependencies.beforeDatabaseOpen?.(kind, selected.mode, cacheKiB);
|
||||
const mainPath = databasePath(current, kind);
|
||||
const source =
|
||||
selected.mode === 'main_only_immutable'
|
||||
? `file:${mainPath}?immutable=1`
|
||||
: mainPath;
|
||||
let client: DatabaseSync | undefined;
|
||||
let output: T;
|
||||
try {
|
||||
client = new DatabaseSync(source, {
|
||||
allowExtension: false,
|
||||
defensive: true,
|
||||
enableDoubleQuotedStringLiterals: false,
|
||||
enableForeignKeyConstraints: true,
|
||||
readOnly: true,
|
||||
timeout: 0,
|
||||
});
|
||||
configureReadOnlyDatabase(client, current.manifest.profile);
|
||||
output = await read(client);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('sealed SQLite inventory failed', error);
|
||||
} finally {
|
||||
if (client !== undefined) client.close();
|
||||
dependencies.afterDatabaseClose?.(kind);
|
||||
}
|
||||
const after = inspectLocalReconciliationSealedBundle(
|
||||
bundle.captureRoot,
|
||||
bundle.receipt.captureId,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
after.receipt.bundleDigest !== current.receipt.bundleDigest ||
|
||||
after.fingerprintDigest !== current.fingerprintDigest
|
||||
) {
|
||||
configurationError('sealed capture bundle drifted after SQLite close');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
+3
-1
@@ -756,7 +756,9 @@ function replayResult(
|
||||
head.state === 'reconciliation_reviewed' ||
|
||||
head.state === 'reconciliation_application_prepared' ||
|
||||
head.state === 'reconciliation_application_planned' ||
|
||||
head.state === 'reconciliation_automation_planned');
|
||||
head.state === 'reconciliation_automation_planned' ||
|
||||
head.state === 'reconciliation_automation_decision_prepared' ||
|
||||
head.state === 'reconciliation_automation_reviewed');
|
||||
if (
|
||||
record.actionId !== intent.actionId ||
|
||||
record.intentDigest !== intent.intentDigest ||
|
||||
|
||||
Reference in New Issue
Block a user