mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): plan reconciliation application
This commit is contained in:
@@ -29,6 +29,8 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_planned'
|
||||
| 'reconciliation_review_prepared'
|
||||
| 'reconciliation_reviewed'
|
||||
| 'reconciliation_application_prepared'
|
||||
| 'reconciliation_application_planned'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -166,6 +168,8 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_planned' &&
|
||||
head.state !== 'reconciliation_review_prepared' &&
|
||||
head.state !== 'reconciliation_reviewed' &&
|
||||
head.state !== 'reconciliation_application_prepared' &&
|
||||
head.state !== 'reconciliation_application_planned' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
head.state !== 'legacy_running' &&
|
||||
@@ -338,6 +342,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_planned'
|
||||
| 'reconciliation_review_prepared'
|
||||
| 'reconciliation_reviewed'
|
||||
| 'reconciliation_application_prepared'
|
||||
| 'reconciliation_application_planned'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
| 'legacy_running'
|
||||
@@ -382,6 +388,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_planned' ||
|
||||
current.state === 'reconciliation_review_prepared' ||
|
||||
current.state === 'reconciliation_reviewed' ||
|
||||
current.state === 'reconciliation_application_prepared' ||
|
||||
current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running' ||
|
||||
current.state === 'legacy_ready')
|
||||
@@ -409,6 +417,10 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_planned') ||
|
||||
(state === 'reconciliation_reviewed' &&
|
||||
current.state === 'reconciliation_review_prepared') ||
|
||||
(state === 'reconciliation_application_prepared' &&
|
||||
current.state === 'reconciliation_reviewed') ||
|
||||
(state === 'reconciliation_application_planned' &&
|
||||
current.state === 'reconciliation_application_prepared') ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
|
||||
@@ -123,6 +123,14 @@ import {
|
||||
verifyLocalReconciliationReview,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
} from './reconciliation/review/completion';
|
||||
import {
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationApplicationCommandFile,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationApplicationCommandFile,
|
||||
verifyLocalReconciliationApplication,
|
||||
verifyLocalReconciliationApplicationCommandFile,
|
||||
} from './reconciliation/application/coordinator';
|
||||
|
||||
export {
|
||||
commitLocalReconciliationPlan,
|
||||
@@ -139,8 +147,42 @@ export {
|
||||
commitLocalReconciliationReviewCommandFile,
|
||||
verifyLocalReconciliationReview,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationApplicationCommandFile,
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationApplicationCommandFile,
|
||||
verifyLocalReconciliationApplication,
|
||||
verifyLocalReconciliationApplicationCommandFile,
|
||||
};
|
||||
|
||||
export {
|
||||
normalizeLocalReconciliationApplicationCommitCommand,
|
||||
normalizeLocalReconciliationApplicationPrepareCommand,
|
||||
normalizeLocalReconciliationApplicationVerifyCommand,
|
||||
type LocalReconciliationApplicationCommitCommand,
|
||||
type LocalReconciliationApplicationOptions,
|
||||
type LocalReconciliationApplicationPrepareCommand,
|
||||
type LocalReconciliationApplicationPrepareResult,
|
||||
type LocalReconciliationApplicationTerminalResult,
|
||||
type LocalReconciliationApplicationVerifyCommand,
|
||||
} from './reconciliation/application/contract';
|
||||
export {
|
||||
localReconciliationApplicationDirectory,
|
||||
normalizeLocalReconciliationApplicationIntent,
|
||||
readLocalReconciliationApplicationIntent,
|
||||
type LocalReconciliationApplicationDependencies,
|
||||
type LocalReconciliationApplicationIntent,
|
||||
} from './reconciliation/application/coordinator';
|
||||
export {
|
||||
normalizeLocalReconciliationApplicationPlan,
|
||||
normalizeLocalReconciliationApplicationPlanReceipt,
|
||||
type LocalReconciliationApplicationDatabaseDecisionSummary,
|
||||
type LocalReconciliationApplicationDomainAction,
|
||||
type LocalReconciliationApplicationDomainSummary,
|
||||
type LocalReconciliationApplicationPlan,
|
||||
type LocalReconciliationApplicationPlanReceipt,
|
||||
} from './reconciliation/application/plan';
|
||||
|
||||
export {
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposeApplyCommand,
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
verifyLocalReconciliationCaptureCommandFile,
|
||||
verifyLocalReconciliationPlanCommandFile,
|
||||
verifyLocalReconciliationReviewCommandFile,
|
||||
prepareLocalReconciliationApplicationCommandFile,
|
||||
commitLocalReconciliationApplicationCommandFile,
|
||||
verifyLocalReconciliationApplicationCommandFile,
|
||||
writeLocalReconciliationReviewDiagnosticsCommandFile,
|
||||
prepareLocalDeploymentCommandFile,
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
@@ -37,7 +40,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|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|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')) {
|
||||
@@ -76,6 +79,9 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-review-diagnostics' &&
|
||||
argv[0] !== 'reconciliation-review-commit' &&
|
||||
argv[0] !== 'reconciliation-review-verify' &&
|
||||
argv[0] !== 'reconciliation-application-prepare' &&
|
||||
argv[0] !== 'reconciliation-application-commit' &&
|
||||
argv[0] !== 'reconciliation-application-verify' &&
|
||||
argv[0] !== 'compose-revision' &&
|
||||
argv[0] !== 'compose-preflight' &&
|
||||
argv[0] !== 'compose-apply' &&
|
||||
@@ -166,6 +172,12 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? commitLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-review-verify'
|
||||
? verifyLocalReconciliationReviewCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-application-prepare'
|
||||
? prepareLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-application-commit'
|
||||
? commitLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-application-verify'
|
||||
? verifyLocalReconciliationApplicationCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-revision'
|
||||
? switchLocalDeploymentComposeRevisionCommandFile(argv[2]!)
|
||||
: argv[0] === 'compose-preflight'
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { currentIdentity } from '../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
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 MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalReconciliationApplicationOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly captureRoot: string;
|
||||
readonly planRoot: string;
|
||||
readonly reviewRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly issuerKeyringPath: string;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPrepareCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.prepare';
|
||||
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
readonly request: Readonly<{
|
||||
applicationId: string;
|
||||
reviewId: string;
|
||||
expectedReviewDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
preparedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationCommitCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.commit';
|
||||
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
readonly request: Readonly<{
|
||||
applicationId: string;
|
||||
expectedPreparationDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
committedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.verify';
|
||||
readonly options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
readonly request: Readonly<{
|
||||
applicationId: string;
|
||||
expectedApplicationPlanDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPrepareResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.application.prepare';
|
||||
readonly status: 'prepared' | 'existing';
|
||||
readonly state: 'reconciliation_application_prepared';
|
||||
readonly applicationId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationTerminalResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| 'local.deployment.reconciliation.application.commit'
|
||||
| 'local.deployment.reconciliation.application.verify';
|
||||
readonly status: 'prepared' | 'existing' | 'verified';
|
||||
readonly state: 'reconciliation_application_planned';
|
||||
readonly applicationId: string;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly outcome:
|
||||
| 'no_effect_ready'
|
||||
| 'adapter_required'
|
||||
| 'manual_required'
|
||||
| 'adapter_and_manual_required';
|
||||
readonly domainCount: 8;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
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 a descendant of 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 identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||
configurationError(`${label} must be a UUID v4`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationOptions> {
|
||||
const options = object(value, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'captureRoot',
|
||||
'deploymentRoot',
|
||||
'issuerKeyringPath',
|
||||
'planRoot',
|
||||
'reviewRoot',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
configurationError(
|
||||
'reconciliation application command identity is invalid',
|
||||
);
|
||||
}
|
||||
const roots = [
|
||||
safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||
safePath(options.captureRoot, 'captureRoot'),
|
||||
safePath(options.planRoot, 'planRoot'),
|
||||
safePath(options.reviewRoot, 'reviewRoot'),
|
||||
safePath(options.applicationRoot, 'applicationRoot'),
|
||||
];
|
||||
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(
|
||||
'deployment, capture, plan, review and application roots must not overlap',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const issuerKeyringPath = safePath(
|
||||
options.issuerKeyringPath,
|
||||
'issuerKeyringPath',
|
||||
);
|
||||
descendant(roots[0]!, issuerKeyringPath, 'issuerKeyringPath');
|
||||
return Object.freeze({
|
||||
deploymentRoot: roots[0]!,
|
||||
captureRoot: roots[1]!,
|
||||
planRoot: roots[2]!,
|
||||
reviewRoot: roots[3]!,
|
||||
applicationRoot: roots[4]!,
|
||||
issuerKeyringPath,
|
||||
allowRootService: options.allowRootService,
|
||||
});
|
||||
}
|
||||
|
||||
function command(
|
||||
value: unknown,
|
||||
operation:
|
||||
| LocalReconciliationApplicationPrepareCommand['operation']
|
||||
| LocalReconciliationApplicationCommitCommand['operation']
|
||||
| LocalReconciliationApplicationVerifyCommand['operation'],
|
||||
): Readonly<{
|
||||
options: Readonly<LocalReconciliationApplicationOptions>;
|
||||
request: Record<string, unknown>;
|
||||
}> {
|
||||
const selected = object(value, 'reconciliation application command');
|
||||
exact(
|
||||
selected,
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'command',
|
||||
);
|
||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
||||
configurationError('reconciliation application command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: normalizeOptions(selected.options),
|
||||
request: object(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationPrepareCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationPrepareCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.application.prepare',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'expectedHeadDigest',
|
||||
'expectedReviewDigest',
|
||||
'preparedAtMs',
|
||||
'reviewId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.preparedAtMs) ||
|
||||
(selected.request.preparedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('reconciliation application prepare command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.prepare',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
'applicationId',
|
||||
),
|
||||
reviewId: identifier(selected.request.reviewId, 'reviewId'),
|
||||
expectedReviewDigest: digest(
|
||||
selected.request.expectedReviewDigest,
|
||||
'expectedReviewDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
preparedAtMs: selected.request.preparedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationCommitCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationCommitCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.application.commit',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'committedAtMs',
|
||||
'expectedHeadDigest',
|
||||
'expectedPreparationDigest',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(selected.request.committedAtMs) ||
|
||||
(selected.request.committedAtMs as number) < 0
|
||||
) {
|
||||
configurationError('reconciliation application commit command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.commit',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
'applicationId',
|
||||
),
|
||||
expectedPreparationDigest: digest(
|
||||
selected.request.expectedPreparationDigest,
|
||||
'expectedPreparationDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
committedAtMs: selected.request.committedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationVerifyCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationVerifyCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.application.verify',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
['applicationId', 'expectedApplicationPlanDigest'],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.verify',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
'applicationId',
|
||||
),
|
||||
expectedApplicationPlanDigest: digest(
|
||||
selected.request.expectedApplicationPlanDigest,
|
||||
'expectedApplicationPlanDigest',
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,510 @@
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import { cutoverDigest } from '../../cutover/targetEvidence';
|
||||
import type {
|
||||
LocalReconciliationReviewAuthorizationDomainDecisionCounts,
|
||||
LocalReconciliationReviewAuthorizationEvidence,
|
||||
} from '../review/authorization';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS,
|
||||
type LocalReconciliationReviewDisposition,
|
||||
} from '../review/decisionFile';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||
type LocalReconciliationPlanDomain,
|
||||
} from '../planning/contract';
|
||||
import type { LocalReconciliationApplicationIntent } from './coordinator';
|
||||
|
||||
const PLAN_SCHEMA = 'qinglong3-local-reconciliation-application-plan';
|
||||
const RECEIPT_SCHEMA =
|
||||
'qinglong3-local-reconciliation-application-plan-receipt';
|
||||
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 MAX_PLAN_BYTES = 64 * 1024;
|
||||
|
||||
export type LocalReconciliationApplicationDomainAction =
|
||||
| 'no_effect'
|
||||
| 'adapter_required'
|
||||
| 'manual_external'
|
||||
| 'adapter_and_manual';
|
||||
|
||||
export interface LocalReconciliationApplicationDatabaseDecisionSummary {
|
||||
readonly decisionCount: number;
|
||||
readonly dispositionCounts: Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationDomainSummary {
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly legacy: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||
readonly target: Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||
readonly action: LocalReconciliationApplicationDomainAction;
|
||||
readonly summaryDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPlan {
|
||||
readonly schema: typeof PLAN_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_application_planned';
|
||||
readonly applicationId: string;
|
||||
readonly reviewId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly preparationDigest: string;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly reviewDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly decisionSetDigest: string;
|
||||
readonly decisionCount: number;
|
||||
readonly committedAtMs: number;
|
||||
readonly domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[];
|
||||
readonly outcome:
|
||||
| 'no_effect_ready'
|
||||
| 'adapter_required'
|
||||
| 'manual_required'
|
||||
| 'adapter_and_manual_required';
|
||||
readonly applicationPlanDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationApplicationPlanReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1;
|
||||
readonly state: 'reconciliation_application_planned';
|
||||
readonly applicationId: string;
|
||||
readonly reviewId: string;
|
||||
readonly preparationDigest: string;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly reviewDigest: string;
|
||||
readonly authorizationDigest: string;
|
||||
readonly decisionSetDigest: string;
|
||||
readonly decisionCount: number;
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly outcome: LocalReconciliationApplicationPlan['outcome'];
|
||||
readonly domainCount: 8;
|
||||
readonly committedAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
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 safeCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function counts(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<LocalReconciliationReviewDisposition, number>> {
|
||||
const selected = object(value, label);
|
||||
exact(selected, LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS, label);
|
||||
if (
|
||||
LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.some(
|
||||
(disposition) => !safeCount(selected[disposition]),
|
||||
)
|
||||
) {
|
||||
configurationError(`${label} is invalid`);
|
||||
}
|
||||
return Object.freeze(selected) as Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
}
|
||||
|
||||
function sumCounts(
|
||||
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
|
||||
): number {
|
||||
const total = LOCAL_RECONCILIATION_REVIEW_DISPOSITIONS.reduce(
|
||||
(sum, disposition) => sum + BigInt(selected[disposition]),
|
||||
0n,
|
||||
);
|
||||
if (total > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
configurationError('application decision count overflowed');
|
||||
}
|
||||
return Number(total);
|
||||
}
|
||||
|
||||
function databaseSummary(
|
||||
selected: Readonly<Record<LocalReconciliationReviewDisposition, number>>,
|
||||
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
|
||||
const dispositionCounts = Object.freeze({ ...selected });
|
||||
return Object.freeze({
|
||||
decisionCount: sumCounts(dispositionCounts),
|
||||
dispositionCounts,
|
||||
});
|
||||
}
|
||||
|
||||
function action(
|
||||
selected: Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>,
|
||||
): LocalReconciliationApplicationDomainAction {
|
||||
const adapter =
|
||||
selected.legacy.adopt_legacy +
|
||||
selected.legacy.retain_both +
|
||||
selected.target.retain_both >
|
||||
0;
|
||||
const manual =
|
||||
selected.legacy.defer +
|
||||
selected.legacy.manual_external +
|
||||
selected.target.defer +
|
||||
selected.target.manual_external >
|
||||
0;
|
||||
if (adapter && manual) return 'adapter_and_manual';
|
||||
if (adapter) return 'adapter_required';
|
||||
if (manual) return 'manual_external';
|
||||
return 'no_effect';
|
||||
}
|
||||
|
||||
function outcome(
|
||||
domains: readonly Readonly<LocalReconciliationApplicationDomainSummary>[],
|
||||
): LocalReconciliationApplicationPlan['outcome'] {
|
||||
const adapter = domains.some(
|
||||
(domain) =>
|
||||
domain.action === 'adapter_required' ||
|
||||
domain.action === 'adapter_and_manual',
|
||||
);
|
||||
const manual = domains.some(
|
||||
(domain) =>
|
||||
domain.action === 'manual_external' ||
|
||||
domain.action === 'adapter_and_manual',
|
||||
);
|
||||
if (adapter && manual) return 'adapter_and_manual_required';
|
||||
if (adapter) return 'adapter_required';
|
||||
if (manual) return 'manual_required';
|
||||
return 'no_effect_ready';
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationApplicationPlan(
|
||||
intent: Readonly<LocalReconciliationApplicationIntent>,
|
||||
authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>,
|
||||
committedAtMs: number,
|
||||
preparedHeadDigest: string,
|
||||
): Readonly<LocalReconciliationApplicationPlan> {
|
||||
if (
|
||||
authorization.domainDecisionCounts.length !== 8 ||
|
||||
authorization.decisionCount !==
|
||||
authorization.domainDecisionCounts.reduce(
|
||||
(total, selected) =>
|
||||
total + sumCounts(selected.legacy) + sumCounts(selected.target),
|
||||
0,
|
||||
)
|
||||
) {
|
||||
configurationError('authorization domain decision summary drifted');
|
||||
}
|
||||
const domains = Object.freeze(
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) => {
|
||||
const selected = authorization.domainDecisionCounts[index];
|
||||
if (!selected || selected.domain !== domain) {
|
||||
configurationError('authorization domain ordering drifted');
|
||||
}
|
||||
const legacy = databaseSummary(selected.legacy);
|
||||
const target = databaseSummary(selected.target);
|
||||
const selectedAction = action(selected);
|
||||
const summaryPayload = Object.freeze({
|
||||
domain,
|
||||
legacy,
|
||||
target,
|
||||
action: selectedAction,
|
||||
});
|
||||
return Object.freeze({
|
||||
...summaryPayload,
|
||||
summaryDigest: cutoverDigest(summaryPayload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
const payload = Object.freeze({
|
||||
schema: PLAN_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_application_planned' as const,
|
||||
applicationId: intent.command.request.applicationId,
|
||||
reviewId: intent.command.request.reviewId,
|
||||
profile: intent.profile,
|
||||
preparationDigest: intent.preparationDigest,
|
||||
preparedHeadDigest,
|
||||
reviewDigest: intent.reviewDigest,
|
||||
authorizationDigest: intent.authorizationDigest,
|
||||
decisionSetDigest: intent.decisionSetDigest,
|
||||
decisionCount: intent.decisionCount,
|
||||
committedAtMs,
|
||||
domains,
|
||||
outcome: outcome(domains),
|
||||
});
|
||||
const plan = Object.freeze({
|
||||
...payload,
|
||||
applicationPlanDigest: cutoverDigest(payload),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(`${JSON.stringify(plan, null, 2)}\n`, 'utf8') >
|
||||
MAX_PLAN_BYTES
|
||||
) {
|
||||
configurationError('reconciliation application plan exceeds 64 KiB');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function normalizeDatabaseSummary(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<LocalReconciliationApplicationDatabaseDecisionSummary> {
|
||||
const selected = object(value, label);
|
||||
exact(selected, ['decisionCount', 'dispositionCounts'], label);
|
||||
const dispositionCounts = counts(
|
||||
selected.dispositionCounts,
|
||||
`${label} disposition counts`,
|
||||
);
|
||||
if (
|
||||
!safeCount(selected.decisionCount) ||
|
||||
selected.decisionCount !== sumCounts(dispositionCounts)
|
||||
) {
|
||||
configurationError(`${label} aggregate drifted`);
|
||||
}
|
||||
return Object.freeze({
|
||||
decisionCount: selected.decisionCount,
|
||||
dispositionCounts,
|
||||
}) as Readonly<LocalReconciliationApplicationDatabaseDecisionSummary>;
|
||||
}
|
||||
|
||||
function normalizeDomain(
|
||||
value: unknown,
|
||||
expectedDomain: LocalReconciliationPlanDomain,
|
||||
): Readonly<LocalReconciliationApplicationDomainSummary> {
|
||||
const selected = object(value, 'reconciliation application domain');
|
||||
exact(
|
||||
selected,
|
||||
['action', 'domain', 'legacy', 'summaryDigest', 'target'],
|
||||
'reconciliation application domain',
|
||||
);
|
||||
const legacy = normalizeDatabaseSummary(selected.legacy, 'legacy summary');
|
||||
const target = normalizeDatabaseSummary(selected.target, 'target summary');
|
||||
const summaryPayload = Object.freeze({
|
||||
domain: selected.domain,
|
||||
legacy,
|
||||
target,
|
||||
action: selected.action,
|
||||
});
|
||||
if (
|
||||
selected.domain !== expectedDomain ||
|
||||
![
|
||||
'no_effect',
|
||||
'adapter_required',
|
||||
'manual_external',
|
||||
'adapter_and_manual',
|
||||
].includes(selected.action as string) ||
|
||||
typeof selected.summaryDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(selected.summaryDigest) ||
|
||||
selected.summaryDigest !== cutoverDigest(summaryPayload)
|
||||
) {
|
||||
configurationError('reconciliation application domain drifted');
|
||||
}
|
||||
const derivedAction = action({
|
||||
domain: expectedDomain,
|
||||
legacy: legacy.dispositionCounts,
|
||||
target: target.dispositionCounts,
|
||||
});
|
||||
if (selected.action !== derivedAction) {
|
||||
configurationError('reconciliation application domain action drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
domain: expectedDomain,
|
||||
legacy,
|
||||
target,
|
||||
action: selected.action,
|
||||
summaryDigest: selected.summaryDigest,
|
||||
}) as Readonly<LocalReconciliationApplicationDomainSummary>;
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationPlan(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationPlan> {
|
||||
const plan = object(value, 'reconciliation application plan');
|
||||
exact(
|
||||
plan,
|
||||
[
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'authorizationDigest',
|
||||
'committedAtMs',
|
||||
'decisionCount',
|
||||
'decisionSetDigest',
|
||||
'domains',
|
||||
'outcome',
|
||||
'preparationDigest',
|
||||
'preparedHeadDigest',
|
||||
'profile',
|
||||
'reviewDigest',
|
||||
'reviewId',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'reconciliation application plan',
|
||||
);
|
||||
if (!Array.isArray(plan.domains) || plan.domains.length !== 8) {
|
||||
configurationError('reconciliation application domains are invalid');
|
||||
}
|
||||
const domains = Object.freeze(
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain, index) =>
|
||||
normalizeDomain((plan.domains as unknown[])[index], domain),
|
||||
),
|
||||
);
|
||||
const { applicationPlanDigest, ...rawPayload } = plan;
|
||||
const payload = Object.freeze({ ...rawPayload, domains });
|
||||
const domainDecisionCount = domains.reduce(
|
||||
(total, domain) =>
|
||||
total + domain.legacy.decisionCount + domain.target.decisionCount,
|
||||
0,
|
||||
);
|
||||
if (
|
||||
plan.schema !== PLAN_SCHEMA ||
|
||||
plan.schemaVersion !== 1 ||
|
||||
plan.state !== 'reconciliation_application_planned' ||
|
||||
typeof plan.applicationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(plan.applicationId) ||
|
||||
typeof plan.reviewId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(plan.reviewId) ||
|
||||
(plan.profile !== 'edge' && plan.profile !== 'standalone') ||
|
||||
!safeCount(plan.decisionCount) ||
|
||||
plan.decisionCount !== domainDecisionCount ||
|
||||
!Number.isSafeInteger(plan.committedAtMs) ||
|
||||
(plan.committedAtMs as number) < 0 ||
|
||||
![
|
||||
'no_effect_ready',
|
||||
'adapter_required',
|
||||
'manual_required',
|
||||
'adapter_and_manual_required',
|
||||
].includes(plan.outcome as string) ||
|
||||
plan.outcome !== outcome(domains) ||
|
||||
[
|
||||
plan.preparationDigest,
|
||||
plan.preparedHeadDigest,
|
||||
plan.reviewDigest,
|
||||
plan.authorizationDigest,
|
||||
plan.decisionSetDigest,
|
||||
applicationPlanDigest,
|
||||
].some(
|
||||
(candidate) =>
|
||||
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||
) ||
|
||||
cutoverDigest(payload) !== applicationPlanDigest
|
||||
) {
|
||||
configurationError('reconciliation application plan drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
...(plan as unknown as LocalReconciliationApplicationPlan),
|
||||
domains,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLocalReconciliationApplicationPlanReceipt(
|
||||
plan: Readonly<LocalReconciliationApplicationPlan>,
|
||||
): Readonly<LocalReconciliationApplicationPlanReceipt> {
|
||||
const payload = Object.freeze({
|
||||
schema: RECEIPT_SCHEMA,
|
||||
schemaVersion: 1 as const,
|
||||
state: 'reconciliation_application_planned' as const,
|
||||
applicationId: plan.applicationId,
|
||||
reviewId: plan.reviewId,
|
||||
preparationDigest: plan.preparationDigest,
|
||||
preparedHeadDigest: plan.preparedHeadDigest,
|
||||
reviewDigest: plan.reviewDigest,
|
||||
authorizationDigest: plan.authorizationDigest,
|
||||
decisionSetDigest: plan.decisionSetDigest,
|
||||
decisionCount: plan.decisionCount,
|
||||
applicationPlanDigest: plan.applicationPlanDigest,
|
||||
outcome: plan.outcome,
|
||||
domainCount: 8 as const,
|
||||
committedAtMs: plan.committedAtMs,
|
||||
});
|
||||
return Object.freeze({ ...payload, receiptDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationApplicationPlanReceipt(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationApplicationPlanReceipt> {
|
||||
const receipt = object(value, 'reconciliation application receipt');
|
||||
exact(
|
||||
receipt,
|
||||
[
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'authorizationDigest',
|
||||
'committedAtMs',
|
||||
'decisionCount',
|
||||
'decisionSetDigest',
|
||||
'domainCount',
|
||||
'outcome',
|
||||
'preparationDigest',
|
||||
'preparedHeadDigest',
|
||||
'receiptDigest',
|
||||
'reviewDigest',
|
||||
'reviewId',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'state',
|
||||
],
|
||||
'reconciliation application receipt',
|
||||
);
|
||||
const { receiptDigest, ...payload } = receipt;
|
||||
if (
|
||||
receipt.schema !== RECEIPT_SCHEMA ||
|
||||
receipt.schemaVersion !== 1 ||
|
||||
receipt.state !== 'reconciliation_application_planned' ||
|
||||
typeof receipt.applicationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(receipt.applicationId) ||
|
||||
typeof receipt.reviewId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(receipt.reviewId) ||
|
||||
!safeCount(receipt.decisionCount) ||
|
||||
receipt.domainCount !== 8 ||
|
||||
!Number.isSafeInteger(receipt.committedAtMs) ||
|
||||
(receipt.committedAtMs as number) < 0 ||
|
||||
![
|
||||
'no_effect_ready',
|
||||
'adapter_required',
|
||||
'manual_required',
|
||||
'adapter_and_manual_required',
|
||||
].includes(receipt.outcome as string) ||
|
||||
[
|
||||
receipt.preparationDigest,
|
||||
receipt.preparedHeadDigest,
|
||||
receipt.reviewDigest,
|
||||
receipt.authorizationDigest,
|
||||
receipt.decisionSetDigest,
|
||||
receipt.applicationPlanDigest,
|
||||
receiptDigest,
|
||||
].some(
|
||||
(candidate) =>
|
||||
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
|
||||
) ||
|
||||
cutoverDigest(payload) !== receiptDigest
|
||||
) {
|
||||
configurationError('reconciliation application receipt drifted');
|
||||
}
|
||||
return receipt as unknown as Readonly<LocalReconciliationApplicationPlanReceipt>;
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../foundation/error';
|
||||
import {
|
||||
LOCAL_RECONCILIATION_PLAN_DOMAINS,
|
||||
type LocalReconciliationPlanDomain,
|
||||
} from '../planning/contract';
|
||||
import type {
|
||||
LocalReconciliationReviewDecision,
|
||||
LocalReconciliationReviewDisposition,
|
||||
@@ -54,9 +58,20 @@ export interface LocalReconciliationReviewAuthorizationEvidence {
|
||||
readonly reasonCounts: Readonly<
|
||||
Record<LocalReconciliationReviewReason, number>
|
||||
>;
|
||||
readonly domainDecisionCounts: readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[];
|
||||
readonly header: Readonly<LocalReconciliationReviewAuthorizationHeader>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewAuthorizationDomainDecisionCounts {
|
||||
readonly domain: LocalReconciliationPlanDomain;
|
||||
readonly legacy: Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
readonly target: Readonly<
|
||||
Record<LocalReconciliationReviewDisposition, number>
|
||||
>;
|
||||
}
|
||||
|
||||
interface SignatureRecord {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: typeof SIGNATURE_KIND;
|
||||
@@ -97,6 +112,32 @@ function zeroCounts<T extends string>(keys: readonly T[]): Record<T, number> {
|
||||
return Object.fromEntries(keys.map((key) => [key, 0])) as Record<T, number>;
|
||||
}
|
||||
|
||||
function zeroDomainDecisionCounts(): Array<{
|
||||
domain: LocalReconciliationPlanDomain;
|
||||
legacy: Record<LocalReconciliationReviewDisposition, number>;
|
||||
target: Record<LocalReconciliationReviewDisposition, number>;
|
||||
}> {
|
||||
return LOCAL_RECONCILIATION_PLAN_DOMAINS.map((domain) => ({
|
||||
domain,
|
||||
legacy: zeroCounts(DISPOSITIONS),
|
||||
target: zeroCounts(DISPOSITIONS),
|
||||
}));
|
||||
}
|
||||
|
||||
function freezeDomainDecisionCounts(
|
||||
counts: ReturnType<typeof zeroDomainDecisionCounts>,
|
||||
): readonly Readonly<LocalReconciliationReviewAuthorizationDomainDecisionCounts>[] {
|
||||
return Object.freeze(
|
||||
counts.map((selected) =>
|
||||
Object.freeze({
|
||||
domain: selected.domain,
|
||||
legacy: Object.freeze({ ...selected.legacy }),
|
||||
target: Object.freeze({ ...selected.target }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation review authorization ${message}`,
|
||||
@@ -429,6 +470,7 @@ export async function publishLocalReconciliationReviewAuthorization(
|
||||
let decisionCount = 0;
|
||||
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
||||
const reasonCounts = zeroCounts(REASONS);
|
||||
const domainDecisionCounts = zeroDomainDecisionCounts();
|
||||
const writeContent = (value: unknown, isDecision: boolean): void => {
|
||||
const line = canonicalLine(value);
|
||||
try {
|
||||
@@ -452,6 +494,11 @@ export async function publishLocalReconciliationReviewAuthorization(
|
||||
decisionCount += 1;
|
||||
dispositionCounts[selected.disposition] += 1;
|
||||
reasonCounts[selected.reason] += 1;
|
||||
const domain = domainDecisionCounts.find(
|
||||
(candidate) => candidate.domain === selected.domain,
|
||||
);
|
||||
if (!domain) configurationError('decision domain is unavailable');
|
||||
domain[selected.database][selected.disposition] += 1;
|
||||
});
|
||||
if (!DIGEST_PATTERN.test(decisionFile.decisionFileDigest)) {
|
||||
configurationError('decision file evidence is invalid');
|
||||
@@ -532,6 +579,7 @@ export async function publishLocalReconciliationReviewAuthorization(
|
||||
keyId: material.keyId,
|
||||
dispositionCounts: Object.freeze(dispositionCounts),
|
||||
reasonCounts: Object.freeze(reasonCounts),
|
||||
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
|
||||
header: options.header,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -668,6 +716,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
||||
}
|
||||
const dispositionCounts = zeroCounts(DISPOSITIONS);
|
||||
const reasonCounts = zeroCounts(REASONS);
|
||||
const domainDecisionCounts = zeroDomainDecisionCounts();
|
||||
let decisionCount = 0;
|
||||
let pending = nextLine();
|
||||
if (pending === null) configurationError('signature is absent');
|
||||
@@ -679,6 +728,11 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
||||
decisionCount += 1;
|
||||
dispositionCounts[selected.disposition] += 1;
|
||||
reasonCounts[selected.reason] += 1;
|
||||
const domain = domainDecisionCounts.find(
|
||||
(candidate) => candidate.domain === selected.domain,
|
||||
);
|
||||
if (!domain) configurationError('decision domain is unavailable');
|
||||
domain[selected.database][selected.disposition] += 1;
|
||||
pending.fill(0);
|
||||
pending = following;
|
||||
}
|
||||
@@ -740,6 +794,7 @@ export async function verifyLocalReconciliationReviewAuthorization(
|
||||
keyId: signature.keyId,
|
||||
dispositionCounts: Object.freeze(dispositionCounts),
|
||||
reasonCounts: Object.freeze(reasonCounts),
|
||||
domainDecisionCounts: freezeDomainDecisionCounts(domainDecisionCounts),
|
||||
header: normalizedHeader,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -504,6 +504,50 @@ async function readTerminal(
|
||||
return Object.freeze({ authorization, review, receipt });
|
||||
}
|
||||
|
||||
export interface LocalReconciliationReviewTerminal {
|
||||
readonly intent: Readonly<LocalReconciliationReviewIntent>;
|
||||
readonly authorization: Readonly<LocalReconciliationReviewAuthorizationEvidence>;
|
||||
readonly review: Readonly<LocalReconciliationReview>;
|
||||
readonly receipt: Readonly<LocalReconciliationReviewReceipt>;
|
||||
}
|
||||
|
||||
export async function readLocalReconciliationReviewTerminal(
|
||||
reviewRoot: string,
|
||||
reviewId: string,
|
||||
issuerKeyringPath: string,
|
||||
uid: number,
|
||||
): Promise<Readonly<LocalReconciliationReviewTerminal>> {
|
||||
const selected = paths(reviewRoot, reviewId);
|
||||
validateDirectory(
|
||||
selected.root,
|
||||
uid,
|
||||
[0o500],
|
||||
'reconciliationReviewDirectory',
|
||||
);
|
||||
validateDirectory(
|
||||
selected.staging,
|
||||
uid,
|
||||
[0o500],
|
||||
'reconciliationReviewStaging',
|
||||
);
|
||||
validateCatalog(selected, true);
|
||||
const intent = readIntent(selected, uid);
|
||||
if (
|
||||
intent.command.options.reviewRoot !== reviewRoot ||
|
||||
intent.command.request.reviewId !== reviewId
|
||||
) {
|
||||
configurationError('terminal reconciliation review path binding drifted');
|
||||
}
|
||||
const terminal = await readTerminal(
|
||||
selected,
|
||||
intent,
|
||||
issuerKeyringPath,
|
||||
uid,
|
||||
[0o400],
|
||||
);
|
||||
return Object.freeze({ intent, ...terminal });
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: LocalReconciliationReviewTerminalResult['operation'],
|
||||
status: LocalReconciliationReviewTerminalResult['status'],
|
||||
|
||||
@@ -286,6 +286,10 @@ export function assertLocalReconciliationReviewDecisionMatchesFact(
|
||||
}
|
||||
if (
|
||||
fact.decisionRequirement === 'informational' ||
|
||||
(fact.database === 'legacy' && selected.disposition === 'retain_target') ||
|
||||
(fact.database === 'target' &&
|
||||
(selected.disposition === 'adopt_legacy' ||
|
||||
selected.disposition === 'exclude_legacy')) ||
|
||||
(fact.decisionRequirement === 'blocked' &&
|
||||
selected.disposition !== 'defer' &&
|
||||
selected.disposition !== 'manual_external') ||
|
||||
|
||||
+3
-1
@@ -753,7 +753,9 @@ function replayResult(
|
||||
head.state === 'reconciliation_plan_prepared' ||
|
||||
head.state === 'reconciliation_planned' ||
|
||||
head.state === 'reconciliation_review_prepared' ||
|
||||
head.state === 'reconciliation_reviewed');
|
||||
head.state === 'reconciliation_reviewed' ||
|
||||
head.state === 'reconciliation_application_prepared' ||
|
||||
head.state === 'reconciliation_application_planned');
|
||||
if (
|
||||
record.actionId !== intent.actionId ||
|
||||
record.intentDigest !== intent.intentDigest ||
|
||||
|
||||
@@ -9,12 +9,15 @@ const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
commitLocalReconciliationCapture,
|
||||
commitLocalReconciliationApplication,
|
||||
commitLocalReconciliationPlan,
|
||||
commitLocalReconciliationReview,
|
||||
prepareLocalReconciliationCapture,
|
||||
prepareLocalReconciliationApplication,
|
||||
prepareLocalReconciliationPlan,
|
||||
prepareLocalReconciliationReview,
|
||||
verifyLocalReconciliationCapture,
|
||||
verifyLocalReconciliationApplication,
|
||||
verifyLocalReconciliationPlan,
|
||||
verifyLocalReconciliationReview,
|
||||
writeLocalReconciliationReviewDiagnostics,
|
||||
@@ -799,6 +802,7 @@ function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') {
|
||||
for (const fact of page.records) {
|
||||
if (fact.decisionRequirement === 'informational') continue;
|
||||
const blocked = fact.decisionRequirement === 'blocked';
|
||||
const legacy = fact.database === 'legacy';
|
||||
decisions.push({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-review-decision',
|
||||
@@ -807,9 +811,15 @@ function writeReviewDecisionFile(state, prepared, fileName = 'review.ndjson') {
|
||||
factKind: fact.factKind,
|
||||
ordinal: fact.ordinal,
|
||||
factDigest: fact.factDigest,
|
||||
disposition: blocked ? 'manual_external' : 'retain_target',
|
||||
disposition: blocked
|
||||
? 'manual_external'
|
||||
: legacy
|
||||
? 'exclude_legacy'
|
||||
: 'retain_target',
|
||||
reason: blocked
|
||||
? 'external_recovery_required'
|
||||
: legacy
|
||||
? 'legacy_excluded'
|
||||
: 'preserve_target',
|
||||
});
|
||||
}
|
||||
@@ -924,6 +934,67 @@ function reviewCommitFixture(t, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function reviewedApplicationFixture(t, options = {}) {
|
||||
const state = reviewCommitFixture(t, options);
|
||||
options.mutateDecisions?.(state.reviewFile.records);
|
||||
if (options.mutateDecisions) {
|
||||
fs.writeFileSync(
|
||||
state.reviewFile.filePath,
|
||||
`${state.reviewFile.records
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
const reviewed = await commitLocalReconciliationReview(
|
||||
state.command,
|
||||
state.dependencies,
|
||||
);
|
||||
const applicationRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
`application-root-${options.reviewSuffix ?? '1'}`,
|
||||
);
|
||||
fs.mkdirSync(applicationRoot, { mode: 0o700 });
|
||||
const applicationId =
|
||||
options.applicationId ?? '00000000-0000-4000-8000-000000000401';
|
||||
const prepareCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.prepare',
|
||||
options: {
|
||||
...state.reviewCommand.options,
|
||||
applicationRoot,
|
||||
issuerKeyringPath: state.issuerKeyringPath,
|
||||
},
|
||||
request: {
|
||||
applicationId,
|
||||
reviewId: state.reviewCommand.request.reviewId,
|
||||
expectedReviewDigest: reviewed.reviewDigest,
|
||||
expectedHeadDigest: reviewed.instanceHeadDigest,
|
||||
preparedAtMs: state.command.request.committedAtMs + 1,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
reviewed,
|
||||
applicationRoot,
|
||||
prepareApplicationCommand: prepareCommand,
|
||||
};
|
||||
}
|
||||
|
||||
function applicationCommitCommand(state, prepared) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.commit',
|
||||
options: state.prepareApplicationCommand.options,
|
||||
request: {
|
||||
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||
expectedPreparationDigest: prepared.preparationDigest,
|
||||
expectedHeadDigest: prepared.instanceHeadDigest,
|
||||
committedAtMs: state.prepareApplicationCommand.request.preparedAtMs + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||
const source =
|
||||
mode === 'main_only_immutable'
|
||||
@@ -2157,6 +2228,35 @@ test('review commit rejects missing and policy-invalid decisions before terminal
|
||||
commitLocalReconciliationReview(blocked.command, blocked.dependencies),
|
||||
/not allowed for canonical fact/,
|
||||
);
|
||||
|
||||
const crossDatabase = reviewCommitFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000390',
|
||||
reviewId: '00000000-0000-4000-8000-000000000391',
|
||||
reviewSuffix: 'cross-database',
|
||||
});
|
||||
const legacyDecision = crossDatabase.reviewFile.records.find(
|
||||
(record) =>
|
||||
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||
record.database === 'legacy' &&
|
||||
record.disposition === 'exclude_legacy',
|
||||
);
|
||||
assert.ok(legacyDecision);
|
||||
legacyDecision.disposition = 'retain_target';
|
||||
legacyDecision.reason = 'preserve_target';
|
||||
fs.writeFileSync(
|
||||
crossDatabase.reviewFile.filePath,
|
||||
`${crossDatabase.reviewFile.records
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationReview(
|
||||
crossDatabase.command,
|
||||
crossDatabase.dependencies,
|
||||
),
|
||||
/not allowed for canonical fact/,
|
||||
);
|
||||
});
|
||||
|
||||
test('review commit rejects weak principals, oversized Edge streams and decision-file drift around signing', async (t) => {
|
||||
@@ -2329,6 +2429,262 @@ test('review commit resumes authorization, receipt, seal and head response-loss
|
||||
assert.equal(headReplay.status, 'existing');
|
||||
});
|
||||
|
||||
test('application coordinator plans eight content-free domains and verifies without SQLite writes', async (t) => {
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000401',
|
||||
reviewId: '00000000-0000-4000-8000-000000000402',
|
||||
applicationId: '00000000-0000-4000-8000-000000000403',
|
||||
reviewSuffix: 'application-terminal',
|
||||
mutateDecisions(records) {
|
||||
const selected = records.find(
|
||||
(record) =>
|
||||
record.kind === 'qinglong3-local-reconciliation-review-decision' &&
|
||||
record.database === 'legacy' &&
|
||||
record.domain === 'automation' &&
|
||||
record.disposition === 'exclude_legacy',
|
||||
);
|
||||
assert.ok(selected);
|
||||
selected.disposition = 'adopt_legacy';
|
||||
selected.reason = 'prefer_legacy';
|
||||
},
|
||||
});
|
||||
const targetBefore = fs.statSync(state.targetDatabasePath, { bigint: true });
|
||||
const targetBytes = fs.readFileSync(state.targetDatabasePath);
|
||||
const prepared = await prepareLocalReconciliationApplication(
|
||||
state.prepareApplicationCommand,
|
||||
);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.state, 'reconciliation_application_prepared');
|
||||
const commitCommand = applicationCommitCommand(state, prepared);
|
||||
const committed = await commitLocalReconciliationApplication(commitCommand);
|
||||
assert.equal(committed.status, 'prepared');
|
||||
assert.equal(committed.state, 'reconciliation_application_planned');
|
||||
assert.equal(committed.domainCount, 8);
|
||||
assert.equal(committed.outcome, 'adapter_and_manual_required');
|
||||
const applicationDirectory = path.join(
|
||||
state.applicationRoot,
|
||||
state.prepareApplicationCommand.request.applicationId,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(applicationDirectory).sort(), [
|
||||
'intent.json',
|
||||
'plan.json',
|
||||
'receipt.json',
|
||||
'staging',
|
||||
]);
|
||||
assert.equal(fs.statSync(applicationDirectory).mode & 0o777, 0o500);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(applicationDirectory, 'staging')).mode & 0o777,
|
||||
0o500,
|
||||
);
|
||||
for (const fileName of ['intent.json', 'plan.json', 'receipt.json']) {
|
||||
assert.equal(
|
||||
fs.statSync(path.join(applicationDirectory, fileName)).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
}
|
||||
const planText = fs.readFileSync(
|
||||
path.join(applicationDirectory, 'plan.json'),
|
||||
'utf8',
|
||||
);
|
||||
const plan = JSON.parse(planText);
|
||||
assert.equal(plan.domains.length, 8);
|
||||
assert.equal(
|
||||
plan.domains.find((domain) => domain.domain === 'automation').action,
|
||||
'adapter_required',
|
||||
);
|
||||
assert.equal(planText.includes('QingLong3TaskDefinitions'), false);
|
||||
assert.equal(planText.includes('Crontabs'), false);
|
||||
assert.equal(planText.includes('private-value'), false);
|
||||
assert.equal(planText.includes('0 0 * * *'), false);
|
||||
const verifyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.verify',
|
||||
options: state.prepareApplicationCommand.options,
|
||||
request: {
|
||||
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||
expectedApplicationPlanDigest: committed.applicationPlanDigest,
|
||||
},
|
||||
};
|
||||
const verified = await verifyLocalReconciliationApplication(verifyCommand);
|
||||
assert.equal(verified.status, 'verified');
|
||||
assert.equal(verified.applicationPlanDigest, committed.applicationPlanDigest);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.captureCommand.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(head.state, 'reconciliation_application_planned');
|
||||
assert.equal(head.sourceRecordDigest, committed.applicationPlanDigest);
|
||||
const targetAfter = fs.statSync(state.targetDatabasePath, { bigint: true });
|
||||
assert.equal(
|
||||
fs.readFileSync(state.targetDatabasePath).equals(targetBytes),
|
||||
true,
|
||||
);
|
||||
assert.equal(targetAfter.mtimeNs, targetBefore.mtimeNs);
|
||||
assert.equal(targetAfter.ctimeNs, targetBefore.ctimeNs);
|
||||
|
||||
const commandPath = path.join(
|
||||
state.deploymentRoot,
|
||||
'application-verify.json',
|
||||
);
|
||||
fs.writeFileSync(commandPath, `${JSON.stringify(verifyCommand)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const cli = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||
'reconciliation-application-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(state.applicationRoot), false);
|
||||
assert.equal(cli.stdout.includes('review-owner'), false);
|
||||
assert.equal(cli.stdout.includes('Crontabs'), false);
|
||||
});
|
||||
|
||||
test('application coordinator resumes every publication window and fences competitors', async (t) => {
|
||||
const prepareCrash = await reviewedApplicationFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000410',
|
||||
reviewId: '00000000-0000-4000-8000-000000000411',
|
||||
applicationId: '00000000-0000-4000-8000-000000000412',
|
||||
reviewSuffix: 'application-prepare-crash',
|
||||
});
|
||||
await assert.rejects(
|
||||
prepareLocalReconciliationApplication(
|
||||
prepareCrash.prepareApplicationCommand,
|
||||
{
|
||||
afterHeadPrepared() {
|
||||
throw new Error('application prepare head response loss');
|
||||
},
|
||||
},
|
||||
),
|
||||
/application prepare head response loss/,
|
||||
);
|
||||
const preparedReplay = await prepareLocalReconciliationApplication(
|
||||
prepareCrash.prepareApplicationCommand,
|
||||
);
|
||||
assert.equal(preparedReplay.state, 'reconciliation_application_prepared');
|
||||
await assert.rejects(
|
||||
prepareLocalReconciliationApplication({
|
||||
...prepareCrash.prepareApplicationCommand,
|
||||
request: {
|
||||
...prepareCrash.prepareApplicationCommand.request,
|
||||
applicationId: '00000000-0000-4000-8000-00000000041f',
|
||||
},
|
||||
}),
|
||||
/lost reviewed head compare-and-swap/,
|
||||
);
|
||||
|
||||
const windows = [
|
||||
['plan', '000000000413', '000000000414', '000000000415'],
|
||||
['receipt', '000000000416', '000000000417', '000000000418'],
|
||||
['seal', '000000000419', '00000000041a', '00000000041b'],
|
||||
['head', '00000000041c', '00000000041d', '00000000041e'],
|
||||
];
|
||||
for (const [window, planTail, reviewTail, applicationTail] of windows) {
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId: `00000000-0000-4000-8000-${planTail}`,
|
||||
reviewId: `00000000-0000-4000-8000-${reviewTail}`,
|
||||
applicationId: `00000000-0000-4000-8000-${applicationTail}`,
|
||||
reviewSuffix: `application-${window}-crash`,
|
||||
});
|
||||
const prepared = await prepareLocalReconciliationApplication(
|
||||
state.prepareApplicationCommand,
|
||||
);
|
||||
const commitCommand = applicationCommitCommand(state, prepared);
|
||||
const callback =
|
||||
window === 'plan'
|
||||
? 'afterPlanPublished'
|
||||
: window === 'receipt'
|
||||
? 'afterReceiptPublished'
|
||||
: window === 'seal'
|
||||
? 'afterTerminalSealed'
|
||||
: 'afterHeadAdvanced';
|
||||
await assert.rejects(
|
||||
commitLocalReconciliationApplication(commitCommand, {
|
||||
[callback]() {
|
||||
throw new Error(`application ${window} response loss`);
|
||||
},
|
||||
}),
|
||||
new RegExp(`application ${window} response loss`),
|
||||
);
|
||||
const replay = await commitLocalReconciliationApplication(commitCommand);
|
||||
assert.equal(replay.state, 'reconciliation_application_planned');
|
||||
if (window === 'head') assert.equal(replay.status, 'existing');
|
||||
}
|
||||
});
|
||||
|
||||
test('application verify rejects digest and head drift without repairing state', async (t) => {
|
||||
const state = await reviewedApplicationFixture(t, {
|
||||
planId: '00000000-0000-4000-8000-000000000421',
|
||||
reviewId: '00000000-0000-4000-8000-000000000422',
|
||||
applicationId: '00000000-0000-4000-8000-000000000423',
|
||||
reviewSuffix: 'application-verify-drift',
|
||||
});
|
||||
const prepared = await prepareLocalReconciliationApplication(
|
||||
state.prepareApplicationCommand,
|
||||
);
|
||||
const committed = await commitLocalReconciliationApplication(
|
||||
applicationCommitCommand(state, prepared),
|
||||
);
|
||||
const verifyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.application.verify',
|
||||
options: state.prepareApplicationCommand.options,
|
||||
request: {
|
||||
applicationId: state.prepareApplicationCommand.request.applicationId,
|
||||
expectedApplicationPlanDigest: 'f'.repeat(64),
|
||||
},
|
||||
};
|
||||
const applicationDirectory = path.join(
|
||||
state.applicationRoot,
|
||||
state.prepareApplicationCommand.request.applicationId,
|
||||
);
|
||||
const before = fs.statSync(path.join(applicationDirectory, 'plan.json'), {
|
||||
bigint: true,
|
||||
});
|
||||
await assert.rejects(
|
||||
verifyLocalReconciliationApplication(verifyCommand),
|
||||
/expected digest drifted/,
|
||||
);
|
||||
const after = fs.statSync(path.join(applicationDirectory, 'plan.json'), {
|
||||
bigint: true,
|
||||
});
|
||||
assert.equal(after.mtimeNs, before.mtimeNs);
|
||||
assert.equal(after.ctimeNs, before.ctimeNs);
|
||||
assert.notEqual(committed.applicationPlanDigest, 'f'.repeat(64));
|
||||
|
||||
const headPath = path.join(
|
||||
state.deploymentRoot,
|
||||
'service',
|
||||
'cutover-instances',
|
||||
state.captureCommand.request.instanceId,
|
||||
'head.json',
|
||||
);
|
||||
const head = JSON.parse(fs.readFileSync(headPath, 'utf8'));
|
||||
delete head.headDigest;
|
||||
head.sourceRecordDigest = 'e'.repeat(64);
|
||||
head.headDigest = digest(head);
|
||||
fs.writeFileSync(headPath, `${JSON.stringify(head, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await assert.rejects(
|
||||
verifyLocalReconciliationApplication({
|
||||
...verifyCommand,
|
||||
request: {
|
||||
...verifyCommand.request,
|
||||
expectedApplicationPlanDigest: committed.applicationPlanDigest,
|
||||
},
|
||||
}),
|
||||
/lost terminal instance head/,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'real stopped Docker target produces an independently verified bundle',
|
||||
{ skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' },
|
||||
|
||||
Reference in New Issue
Block a user