mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(local): publish secret config reconciliation plans
This commit is contained in:
@@ -37,6 +37,7 @@ export type LocalCutoverInstanceHeadState =
|
||||
| 'reconciliation_automation_apply_prepared'
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'reconciliation_secret_config_planned'
|
||||
| 'reconciliation_completed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
@@ -183,6 +184,7 @@ function parseHead(value: unknown): Readonly<LocalCutoverInstanceHead> {
|
||||
head.state !== 'reconciliation_automation_apply_prepared' &&
|
||||
head.state !== 'reconciliation_automation_applied' &&
|
||||
head.state !== 'reconciliation_automation_rolled_back' &&
|
||||
head.state !== 'reconciliation_secret_config_planned' &&
|
||||
head.state !== 'reconciliation_completed' &&
|
||||
head.state !== 'rollback_prepared' &&
|
||||
head.state !== 'legacy_restart_requested' &&
|
||||
@@ -364,6 +366,7 @@ export function advanceLocalCutoverInstanceHead(
|
||||
| 'reconciliation_automation_apply_prepared'
|
||||
| 'reconciliation_automation_applied'
|
||||
| 'reconciliation_automation_rolled_back'
|
||||
| 'reconciliation_secret_config_planned'
|
||||
| 'reconciliation_completed'
|
||||
| 'rollback_prepared'
|
||||
| 'legacy_restart_requested'
|
||||
@@ -417,6 +420,7 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_automation_apply_prepared' ||
|
||||
current.state === 'reconciliation_automation_applied' ||
|
||||
current.state === 'reconciliation_automation_rolled_back' ||
|
||||
current.state === 'reconciliation_secret_config_planned' ||
|
||||
current.state === 'reconciliation_completed' ||
|
||||
current.state === 'legacy_restart_requested' ||
|
||||
current.state === 'legacy_running' ||
|
||||
@@ -462,6 +466,9 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_automation_apply_prepared') ||
|
||||
(state === 'reconciliation_automation_rolled_back' &&
|
||||
current.state === 'reconciliation_automation_applied') ||
|
||||
(state === 'reconciliation_secret_config_planned' &&
|
||||
(current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'reconciliation_automation_applied')) ||
|
||||
(state === 'reconciliation_completed' &&
|
||||
(current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'reconciliation_automation_applied')) ||
|
||||
|
||||
@@ -154,6 +154,13 @@ import {
|
||||
verifyLocalReconciliationAutomationApply,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
} from './reconciliation/application/automation/applyCoordinator';
|
||||
import {
|
||||
planLocalReconciliationSecretConfig,
|
||||
planLocalReconciliationSecretConfigCommandFile,
|
||||
readLocalReconciliationSecretConfigTerminal,
|
||||
verifyLocalReconciliationSecretConfigPlan,
|
||||
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||
} from './reconciliation/application/secret-and-config/coordinator';
|
||||
import {
|
||||
preserveLocalReconciliationRunHistory,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
@@ -206,6 +213,11 @@ export {
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
rollbackLocalReconciliationAutomationApply,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
planLocalReconciliationSecretConfig,
|
||||
planLocalReconciliationSecretConfigCommandFile,
|
||||
readLocalReconciliationSecretConfigTerminal,
|
||||
verifyLocalReconciliationSecretConfigPlan,
|
||||
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||
preserveLocalReconciliationRunHistory,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
readLocalReconciliationRunHistoryTerminal,
|
||||
@@ -325,6 +337,31 @@ export {
|
||||
type LocalReconciliationAutomationPlanSummary,
|
||||
type LocalReconciliationAutomationRowRequirement,
|
||||
} from './reconciliation/application/automation/rowPlan';
|
||||
export {
|
||||
normalizeLocalReconciliationSecretConfigPlanCommand,
|
||||
normalizeLocalReconciliationSecretConfigVerifyCommand,
|
||||
type LocalReconciliationSecretConfigOptions,
|
||||
type LocalReconciliationSecretConfigPlanCommand,
|
||||
type LocalReconciliationSecretConfigPlanResult,
|
||||
type LocalReconciliationSecretConfigVerifyCommand,
|
||||
} from './reconciliation/application/secret-and-config/contract';
|
||||
export {
|
||||
type LocalReconciliationSecretConfigPlanDependencies,
|
||||
type LocalReconciliationSecretConfigTerminal,
|
||||
} from './reconciliation/application/secret-and-config/coordinator';
|
||||
export {
|
||||
MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||
MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||
normalizeLocalReconciliationSecretConfigPlanReceipt,
|
||||
type LocalReconciliationSecretConfigCandidateRequirement,
|
||||
type LocalReconciliationSecretConfigPlanCandidate,
|
||||
type LocalReconciliationSecretConfigPlanFooter,
|
||||
type LocalReconciliationSecretConfigPlanHeader,
|
||||
type LocalReconciliationSecretConfigPlanReceipt,
|
||||
type LocalReconciliationSecretConfigPlanRow,
|
||||
type LocalReconciliationSecretConfigPlanSummary,
|
||||
type LocalReconciliationSecretConfigTarget,
|
||||
} from './reconciliation/application/secret-and-config/rowPlan';
|
||||
|
||||
export {
|
||||
LocalDeploymentConfigurationError,
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
applyLocalReconciliationAutomationCommandFile,
|
||||
verifyLocalReconciliationAutomationApplyCommandFile,
|
||||
rollbackLocalReconciliationAutomationApplyCommandFile,
|
||||
planLocalReconciliationSecretConfigCommandFile,
|
||||
verifyLocalReconciliationSecretConfigPlanCommandFile,
|
||||
preserveLocalReconciliationRunHistoryCommandFile,
|
||||
verifyLocalReconciliationRunHistoryCommandFile,
|
||||
completeLocalReconciliationCommandFile,
|
||||
@@ -52,7 +54,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|reconciliation-automation-decision-prepare|reconciliation-automation-decision-commit|reconciliation-automation-decision-verify|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-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|reconciliation-automation-apply|reconciliation-automation-apply-verify|reconciliation-automation-apply-rollback|reconciliation-secret-config-plan|reconciliation-secret-config-verify|reconciliation-run-history-preserve|reconciliation-run-history-verify|reconciliation-complete|reconciliation-complete-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')) {
|
||||
@@ -102,6 +104,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
argv[0] !== 'reconciliation-automation-apply' &&
|
||||
argv[0] !== 'reconciliation-automation-apply-verify' &&
|
||||
argv[0] !== 'reconciliation-automation-apply-rollback' &&
|
||||
argv[0] !== 'reconciliation-secret-config-plan' &&
|
||||
argv[0] !== 'reconciliation-secret-config-verify' &&
|
||||
argv[0] !== 'reconciliation-run-history-preserve' &&
|
||||
argv[0] !== 'reconciliation-run-history-verify' &&
|
||||
argv[0] !== 'reconciliation-complete' &&
|
||||
@@ -218,6 +222,10 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
? verifyLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-automation-apply-rollback'
|
||||
? rollbackLocalReconciliationAutomationApplyCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-secret-config-plan'
|
||||
? planLocalReconciliationSecretConfigCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-secret-config-verify'
|
||||
? verifyLocalReconciliationSecretConfigPlanCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-run-history-preserve'
|
||||
? preserveLocalReconciliationRunHistoryCommandFile(argv[2]!)
|
||||
: argv[0] === 'reconciliation-run-history-verify'
|
||||
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
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 SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalReconciliationSecretConfigOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly secretConfigRoot: string;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigPlanCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.secret-config.plan';
|
||||
readonly options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||
readonly request: Readonly<{
|
||||
secretConfigId: string;
|
||||
applicationId: string;
|
||||
expectedApplicationPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
decisionFilePath: string;
|
||||
projectId: string;
|
||||
preparedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigVerifyCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.reconciliation.secret-config.verify';
|
||||
readonly options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||
readonly request: Readonly<{
|
||||
secretConfigId: string;
|
||||
expectedSecretConfigPlanDigest: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigPlanResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
| LocalReconciliationSecretConfigPlanCommand['operation']
|
||||
| LocalReconciliationSecretConfigVerifyCommand['operation'];
|
||||
readonly status: 'prepared' | 'existing' | 'verified';
|
||||
readonly state: 'reconciliation_secret_config_planned';
|
||||
readonly secretConfigId: string;
|
||||
readonly secretConfigPlanDigest: string;
|
||||
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
|
||||
readonly rowCount: number;
|
||||
readonly eligibleBindingCount: number;
|
||||
readonly eligiblePreservationCount: number;
|
||||
readonly targetConflictCount: number;
|
||||
readonly adoptedLegacyTaskCount: number;
|
||||
readonly unadaptedLegacyConfigCount: number;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config ${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 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 lowercase UUID v4`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectId(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 128 ||
|
||||
/[\u0000-\u001f\u007f]/u.test(value)
|
||||
) {
|
||||
configurationError('projectId is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigOptions> {
|
||||
const options = object(value, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'deploymentRoot',
|
||||
'secretConfigRoot',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
const identity = currentIdentity();
|
||||
if (
|
||||
typeof options.allowRootService !== 'boolean' ||
|
||||
(identity.uid === 0) !== options.allowRootService
|
||||
) {
|
||||
configurationError('command identity is invalid');
|
||||
}
|
||||
const roots = [
|
||||
safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||
safePath(options.applicationRoot, 'applicationRoot'),
|
||||
safePath(options.secretConfigRoot, 'secretConfigRoot'),
|
||||
];
|
||||
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: roots[0]!,
|
||||
applicationRoot: roots[1]!,
|
||||
secretConfigRoot: roots[2]!,
|
||||
allowRootService: options.allowRootService,
|
||||
});
|
||||
}
|
||||
|
||||
function command(
|
||||
value: unknown,
|
||||
operation:
|
||||
| LocalReconciliationSecretConfigPlanCommand['operation']
|
||||
| LocalReconciliationSecretConfigVerifyCommand['operation'],
|
||||
): Readonly<{
|
||||
options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||
request: Record<string, unknown>;
|
||||
}> {
|
||||
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: normalizeOptions(selected.options),
|
||||
request: object(selected.request, 'request'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.secret-config.plan',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
'decisionFilePath',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
'preparedAtMs',
|
||||
'projectId',
|
||||
'secretConfigId',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
const decisionFilePath = safePath(
|
||||
selected.request.decisionFilePath,
|
||||
'decisionFilePath',
|
||||
);
|
||||
if (
|
||||
[
|
||||
selected.options.deploymentRoot,
|
||||
selected.options.applicationRoot,
|
||||
selected.options.secretConfigRoot,
|
||||
].some(
|
||||
(root) =>
|
||||
overlaps(root, decisionFilePath) || overlaps(decisionFilePath, root),
|
||||
)
|
||||
) {
|
||||
configurationError('decisionFilePath overlaps an authority root');
|
||||
}
|
||||
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.secret-config.plan',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
secretConfigId: identifier(
|
||||
selected.request.secretConfigId,
|
||||
'secretConfigId',
|
||||
),
|
||||
applicationId: identifier(
|
||||
selected.request.applicationId,
|
||||
'applicationId',
|
||||
),
|
||||
expectedApplicationPlanDigest: digest(
|
||||
selected.request.expectedApplicationPlanDigest,
|
||||
'expectedApplicationPlanDigest',
|
||||
),
|
||||
expectedHeadDigest: digest(
|
||||
selected.request.expectedHeadDigest,
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
decisionFilePath,
|
||||
projectId: projectId(selected.request.projectId),
|
||||
preparedAtMs: selected.request.preparedAtMs as number,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalReconciliationSecretConfigVerifyCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigVerifyCommand> {
|
||||
const selected = command(
|
||||
value,
|
||||
'local.deployment.reconciliation.secret-config.verify',
|
||||
);
|
||||
exact(
|
||||
selected.request,
|
||||
['expectedSecretConfigPlanDigest', 'secretConfigId'],
|
||||
'request',
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.verify',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
secretConfigId: identifier(
|
||||
selected.request.secretConfigId,
|
||||
'secretConfigId',
|
||||
),
|
||||
expectedSecretConfigPlanDigest: digest(
|
||||
selected.request.expectedSecretConfigPlanDigest,
|
||||
'expectedSecretConfigPlanDigest',
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
+863
@@ -0,0 +1,863 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
publishExactFile,
|
||||
syncPublishedDirectory,
|
||||
validatePrivateDirectory,
|
||||
} from '../../../foundation/files';
|
||||
import {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
readLocalCutoverInstanceHead,
|
||||
type LocalCutoverInstanceHead,
|
||||
} from '../../../cutover/instanceLineage';
|
||||
import { readLocalReconciliationPlanTerminal } from '../../planning/preparation';
|
||||
import {
|
||||
assertLocalReconciliationReviewDecisionMatchesFact,
|
||||
withLocalReconciliationReviewDecisionFile,
|
||||
} from '../../review/decisionFile';
|
||||
import { visitLocalReconciliationDiagnosticFacts } from '../../review/diagnostics';
|
||||
import { withLocalReconciliationSealedDatabase } from '../../sealed-bundle/reader';
|
||||
import {
|
||||
readLocalReconciliationApplicationTerminal,
|
||||
type LocalReconciliationApplicationTerminal,
|
||||
} from '../coordinator';
|
||||
import {
|
||||
normalizeLocalReconciliationSecretConfigPlanCommand,
|
||||
normalizeLocalReconciliationSecretConfigVerifyCommand,
|
||||
type LocalReconciliationSecretConfigPlanCommand,
|
||||
type LocalReconciliationSecretConfigPlanResult,
|
||||
} from './contract';
|
||||
import {
|
||||
buildLocalReconciliationSecretConfigPlanReceipt,
|
||||
hashLocalReconciliationSecretConfigPlanFile,
|
||||
MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||
MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES,
|
||||
normalizeLocalReconciliationSecretConfigPlanReceipt,
|
||||
writeLocalReconciliationSecretConfigPlan,
|
||||
type LocalReconciliationSecretConfigPlanHeader,
|
||||
type LocalReconciliationSecretConfigPlanReceipt,
|
||||
} from './rowPlan';
|
||||
|
||||
const MAX_RECEIPT_BYTES = 64 * 1024;
|
||||
|
||||
export interface LocalReconciliationSecretConfigPlanDependencies {
|
||||
readonly beforeDatabaseOpen?: (
|
||||
kind: 'legacy' | 'target',
|
||||
mode: 'main_only_immutable' | 'wal_shm_readonly',
|
||||
cacheKiB: 2_048 | 8_192,
|
||||
) => void;
|
||||
readonly afterDatabaseClose?: (kind: 'legacy' | 'target') => void;
|
||||
readonly afterPlanPublished?: () => void;
|
||||
readonly afterReceiptPublished?: () => void;
|
||||
readonly afterTerminalSealed?: () => void;
|
||||
readonly afterHeadAdvanced?: () => void;
|
||||
}
|
||||
|
||||
interface SecretConfigPaths {
|
||||
readonly root: string;
|
||||
readonly staging: string;
|
||||
readonly plan: string;
|
||||
readonly planStage: string;
|
||||
readonly receipt: string;
|
||||
}
|
||||
|
||||
interface SecretConfigReviewAuthority {
|
||||
readonly tableDisposition: 'absent' | 'manual_external';
|
||||
readonly unadaptedLegacyConfigCount: number;
|
||||
readonly decisionFileDigest: string;
|
||||
readonly confirmDecisionFileIdentity: () => void;
|
||||
readonly planTerminal: ReturnType<typeof readLocalReconciliationPlanTerminal>;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
function secretConfigPaths(
|
||||
secretConfigRoot: string,
|
||||
secretConfigId: string,
|
||||
): Readonly<SecretConfigPaths> {
|
||||
const root = path.join(secretConfigRoot, secretConfigId);
|
||||
const staging = path.join(root, 'staging');
|
||||
return Object.freeze({
|
||||
root,
|
||||
staging,
|
||||
plan: path.join(root, 'plan.ndjson'),
|
||||
planStage: path.join(staging, 'plan.ndjson.stage'),
|
||||
receipt: path.join(root, 'receipt.json'),
|
||||
});
|
||||
}
|
||||
|
||||
function validateCatalog(
|
||||
selected: Readonly<SecretConfigPaths>,
|
||||
terminal: boolean,
|
||||
): void {
|
||||
const allowed = new Set([
|
||||
'plan.ndjson',
|
||||
'receipt.json',
|
||||
'staging',
|
||||
...(!terminal ? ['.receipt.json.ql3-deploy-stage'] : []),
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||
if (!allowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
configurationError('plan root contains unknown material');
|
||||
}
|
||||
}
|
||||
const stagingEntries = fs.readdirSync(selected.staging);
|
||||
if (
|
||||
terminal
|
||||
? stagingEntries.length !== 0
|
||||
: stagingEntries.some((entry) => entry !== 'plan.ndjson.stage')
|
||||
) {
|
||||
configurationError('plan staging contains unknown material');
|
||||
}
|
||||
}
|
||||
|
||||
function validateDirectory(
|
||||
directory: string,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
label: string,
|
||||
): number {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
return configurationError(`${label} is unavailable`, error);
|
||||
}
|
||||
const mode = stat.mode & 0o777;
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
!modes.includes(mode) ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
configurationError(`${label} identity is invalid`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
function readReceipt(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
allowedModes: readonly number[],
|
||||
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||
let descriptor: number | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
!allowedModes.includes(Number(before.mode) & 0o777) ||
|
||||
before.nlink !== 1n ||
|
||||
before.size < 2n ||
|
||||
before.size > BigInt(MAX_RECEIPT_BYTES)
|
||||
) {
|
||||
configurationError('receipt 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
|
||||
) {
|
||||
configurationError('receipt changed while opening');
|
||||
}
|
||||
bytes = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const current = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
current.dev !== before.dev ||
|
||||
current.ino !== before.ino ||
|
||||
current.mtimeNs !== before.mtimeNs ||
|
||||
current.ctimeNs !== before.ctimeNs ||
|
||||
current.mode !== before.mode ||
|
||||
current.nlink !== before.nlink
|
||||
) {
|
||||
configurationError('receipt changed while reading');
|
||||
}
|
||||
return normalizeLocalReconciliationSecretConfigPlanReceipt(
|
||||
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('receipt cannot be read', error);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePlanFile(
|
||||
filePath: string,
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
uid: number,
|
||||
allowedModes: readonly number[],
|
||||
): void {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
!allowedModes.includes(Number(before.mode) & 0o777) ||
|
||||
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 ||
|
||||
hashLocalReconciliationSecretConfigPlanFile(
|
||||
descriptor,
|
||||
receipt.planFileBytes,
|
||||
) !== receipt.planFileDigest
|
||||
) {
|
||||
configurationError('plan file content drifted');
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const current = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.mtimeNs !== before.mtimeNs ||
|
||||
after.ctimeNs !== before.ctimeNs ||
|
||||
current.dev !== before.dev ||
|
||||
current.ino !== before.ino ||
|
||||
current.mtimeNs !== before.mtimeNs ||
|
||||
current.ctimeNs !== before.ctimeNs
|
||||
) {
|
||||
configurationError('plan file changed while verifying');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
configurationError('plan file cannot be verified', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function receiptContents(
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
): string {
|
||||
const contents = `${JSON.stringify(receipt, null, 2)}\n`;
|
||||
if (Buffer.byteLength(contents, 'utf8') > MAX_RECEIPT_BYTES) {
|
||||
configurationError('receipt exceeds 64 KiB');
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
function expectedPriorState(
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
): 'reconciliation_application_planned' | 'reconciliation_automation_applied' {
|
||||
const automation = terminal.plan.domains.find(
|
||||
(selected) => selected.domain === 'automation',
|
||||
);
|
||||
if (!automation) configurationError('Automation domain summary is missing');
|
||||
return automation.action === 'adapter_required' ||
|
||||
automation.action === 'adapter_and_manual'
|
||||
? 'reconciliation_automation_applied'
|
||||
: 'reconciliation_application_planned';
|
||||
}
|
||||
|
||||
function validateApplicationBinding(
|
||||
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
): void {
|
||||
const secretConfig = terminal.plan.domains.find(
|
||||
(selected) => selected.domain === 'secret_and_config',
|
||||
);
|
||||
const priorState = expectedPriorState(terminal);
|
||||
if (
|
||||
terminal.intent.command.options.deploymentRoot !==
|
||||
command.options.deploymentRoot ||
|
||||
terminal.intent.command.options.applicationRoot !==
|
||||
command.options.applicationRoot ||
|
||||
terminal.plan.applicationId !== command.request.applicationId ||
|
||||
terminal.plan.applicationPlanDigest !==
|
||||
command.request.expectedApplicationPlanDigest ||
|
||||
terminal.head.state !== 'reconciliation_application_planned' ||
|
||||
terminal.head.sourceRecordDigest !== terminal.plan.applicationPlanDigest ||
|
||||
!secretConfig ||
|
||||
secretConfig.action !== 'manual_external' ||
|
||||
head.state !== priorState ||
|
||||
head.headDigest !== command.request.expectedHeadDigest ||
|
||||
(priorState === 'reconciliation_application_planned' &&
|
||||
head.sourceRecordDigest !== terminal.plan.applicationPlanDigest) ||
|
||||
command.request.preparedAtMs < terminal.plan.committedAtMs ||
|
||||
command.request.preparedAtMs < head.updatedAtMs
|
||||
) {
|
||||
configurationError('plan is detached from its ordered application head');
|
||||
}
|
||||
}
|
||||
|
||||
function reviewAuthority(
|
||||
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||
uid: number,
|
||||
): Readonly<SecretConfigReviewAuthority> {
|
||||
const planTerminal = readLocalReconciliationPlanTerminal(
|
||||
terminal.review.intent.command.options.planRoot,
|
||||
terminal.review.intent.command.request.planId,
|
||||
uid,
|
||||
);
|
||||
let envDisposition: 'absent' | 'manual_external' = 'absent';
|
||||
let envSeen = false;
|
||||
let unadaptedLegacyConfigCount = 0;
|
||||
const reviewed = withLocalReconciliationReviewDecisionFile(
|
||||
command.request.decisionFilePath,
|
||||
{
|
||||
reviewId: terminal.review.review.reviewId,
|
||||
profile: terminal.plan.profile,
|
||||
planDigest: planTerminal.plan.planDigest,
|
||||
preparationDigest: terminal.review.intent.preparationDigest,
|
||||
},
|
||||
(cursor) => {
|
||||
for (const database of ['legacy', 'target'] as const) {
|
||||
const opened = withLocalReconciliationSealedDatabase(
|
||||
planTerminal.bundle,
|
||||
database,
|
||||
uid,
|
||||
dependencies,
|
||||
(client) =>
|
||||
visitLocalReconciliationDiagnosticFacts(
|
||||
client,
|
||||
database,
|
||||
(fact) => {
|
||||
if (fact.decisionRequirement === 'informational') return;
|
||||
const decision = cursor.next();
|
||||
if (decision === null) {
|
||||
configurationError('decision file omitted a canonical fact');
|
||||
}
|
||||
assertLocalReconciliationReviewDecisionMatchesFact(
|
||||
decision,
|
||||
fact,
|
||||
);
|
||||
if (fact.domain !== 'secret_and_config') return;
|
||||
if (decision.disposition !== 'manual_external') {
|
||||
configurationError(
|
||||
'Secret/Config facts require explicit external custody review',
|
||||
);
|
||||
}
|
||||
if (
|
||||
fact.database === 'legacy' &&
|
||||
fact.factKind === 'table' &&
|
||||
fact.tableName === 'Envs'
|
||||
) {
|
||||
if (envSeen) {
|
||||
configurationError('legacy Envs authority is ambiguous');
|
||||
}
|
||||
envSeen = true;
|
||||
envDisposition = 'manual_external';
|
||||
}
|
||||
if (
|
||||
fact.database === 'legacy' &&
|
||||
fact.factKind === 'table' &&
|
||||
fact.tableName === 'Configs'
|
||||
) {
|
||||
unadaptedLegacyConfigCount += 1;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
if (opened === null) {
|
||||
configurationError('manual-required SQLite topology cannot be adapted');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (
|
||||
reviewed.evidence.fileDigest !==
|
||||
terminal.review.authorization.decisionFileDigest ||
|
||||
reviewed.evidence.decisionCount !==
|
||||
terminal.review.authorization.decisionCount
|
||||
) {
|
||||
configurationError('signed review authority drifted');
|
||||
}
|
||||
return Object.freeze({
|
||||
tableDisposition: envDisposition,
|
||||
unadaptedLegacyConfigCount,
|
||||
decisionFileDigest: reviewed.evidence.fileDigest,
|
||||
confirmDecisionFileIdentity: reviewed.confirmIdentity,
|
||||
planTerminal,
|
||||
});
|
||||
}
|
||||
|
||||
function maxPlanBytes(profile: 'edge' | 'standalone'): number {
|
||||
return profile === 'edge'
|
||||
? MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES
|
||||
: MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES;
|
||||
}
|
||||
|
||||
function publishPlan(
|
||||
selected: Readonly<SecretConfigPaths>,
|
||||
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
authority: Readonly<SecretConfigReviewAuthority>,
|
||||
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||
let descriptor: number | undefined;
|
||||
let createdStage = false;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
selected.planStage,
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
fs.constants.O_WRONLY |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
createdStage = true;
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
const header: Omit<
|
||||
LocalReconciliationSecretConfigPlanHeader,
|
||||
'headerDigest'
|
||||
> = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-secret-config-plan-header',
|
||||
secretConfigId: command.request.secretConfigId,
|
||||
applicationId: command.request.applicationId,
|
||||
applicationPlanDigest: terminal.plan.applicationPlanDigest,
|
||||
reviewDigest: terminal.review.review.reviewDigest,
|
||||
reviewAuthorizationDigest:
|
||||
terminal.review.authorization.authorizationDigest,
|
||||
reviewDecisionSetDigest: terminal.review.authorization.decisionSetDigest,
|
||||
reviewDecisionFileDigest: authority.decisionFileDigest,
|
||||
bundleDigest: authority.planTerminal.bundle.receipt.bundleDigest,
|
||||
bundleFingerprintDigest: authority.planTerminal.bundle.fingerprintDigest,
|
||||
profile: terminal.plan.profile,
|
||||
projectId: command.request.projectId,
|
||||
tableDisposition: authority.tableDisposition,
|
||||
unadaptedLegacyConfigCount: authority.unadaptedLegacyConfigCount,
|
||||
preparedHeadDigest: head.headDigest,
|
||||
preparedAtMs: command.request.preparedAtMs,
|
||||
});
|
||||
const generated = withLocalReconciliationSealedDatabase(
|
||||
authority.planTerminal.bundle,
|
||||
'target',
|
||||
uid,
|
||||
dependencies,
|
||||
(target) =>
|
||||
withLocalReconciliationSealedDatabase(
|
||||
authority.planTerminal.bundle,
|
||||
'legacy',
|
||||
uid,
|
||||
dependencies,
|
||||
(legacy) =>
|
||||
writeLocalReconciliationSecretConfigPlan({
|
||||
descriptor: descriptor!,
|
||||
maxBytes: maxPlanBytes(terminal.plan.profile),
|
||||
header,
|
||||
legacy,
|
||||
target,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (generated === null || generated === undefined) {
|
||||
configurationError('manual-required SQLite topology cannot be planned');
|
||||
}
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
authority.confirmDecisionFileIdentity();
|
||||
const receipt = buildLocalReconciliationSecretConfigPlanReceipt(
|
||||
generated.header,
|
||||
generated.footer,
|
||||
generated.fileBytes,
|
||||
generated.fileDigest,
|
||||
);
|
||||
if (fs.existsSync(selected.plan)) {
|
||||
validatePlanFile(selected.plan, receipt, uid, [0o600]);
|
||||
} else {
|
||||
try {
|
||||
fs.linkSync(selected.planStage, selected.plan);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
validatePlanFile(selected.plan, receipt, uid, [0o600]);
|
||||
}
|
||||
syncPublishedDirectory(selected.root);
|
||||
}
|
||||
fs.unlinkSync(selected.planStage);
|
||||
createdStage = false;
|
||||
syncPublishedDirectory(selected.staging);
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('plan cannot be published', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
if (createdStage) {
|
||||
try {
|
||||
fs.unlinkSync(selected.planStage);
|
||||
} catch {
|
||||
// A complete stage remains recoverable; a partial stage fails closed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sealFile(filePath: string, uid: number): void {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
![0o600, 0o400].includes(Number(before.mode) & 0o777) ||
|
||||
before.nlink !== 1n
|
||||
) {
|
||||
configurationError('terminal file cannot be sealed');
|
||||
}
|
||||
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
|
||||
) {
|
||||
configurationError('terminal file changed while sealing');
|
||||
}
|
||||
if ((Number(opened.mode) & 0o777) !== 0o400) {
|
||||
fs.fchmodSync(descriptor, 0o400);
|
||||
}
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function sealDirectory(directory: string, uid: number): void {
|
||||
const mode = validateDirectory(
|
||||
directory,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'terminal directory',
|
||||
);
|
||||
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
if (mode !== 0o500) fs.fchmodSync(descriptor, 0o500);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function sealTerminal(selected: Readonly<SecretConfigPaths>, uid: number): void {
|
||||
if (fs.readdirSync(selected.staging).length !== 0) {
|
||||
configurationError('staging must be empty before terminal seal');
|
||||
}
|
||||
sealFile(selected.plan, uid);
|
||||
sealFile(selected.receipt, uid);
|
||||
sealDirectory(selected.staging, uid);
|
||||
sealDirectory(selected.root, uid);
|
||||
validateCatalog(selected, true);
|
||||
}
|
||||
|
||||
function advanceHead(
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
uid: number,
|
||||
): Readonly<LocalCutoverInstanceHead> {
|
||||
return advanceLocalCutoverInstanceHead(
|
||||
{
|
||||
options: {
|
||||
deploymentRoot: terminal.intent.command.options.deploymentRoot,
|
||||
},
|
||||
request: {
|
||||
cutoverId: terminal.intent.cutoverId,
|
||||
profile: terminal.intent.profile,
|
||||
instanceId: terminal.intent.instanceId,
|
||||
expectedActivationDigest: terminal.intent.activationDigest,
|
||||
requestedAtMs: receipt.preparedAtMs,
|
||||
},
|
||||
},
|
||||
uid,
|
||||
'reconciliation_secret_config_planned',
|
||||
terminal.intent.generation,
|
||||
receipt.secretConfigPlanDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function result(
|
||||
operation: LocalReconciliationSecretConfigPlanResult['operation'],
|
||||
status: LocalReconciliationSecretConfigPlanResult['status'],
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanResult> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
status,
|
||||
state: 'reconciliation_secret_config_planned',
|
||||
secretConfigId: receipt.secretConfigId,
|
||||
secretConfigPlanDigest: receipt.secretConfigPlanDigest,
|
||||
outcome: receipt.outcome,
|
||||
rowCount: receipt.rowCount,
|
||||
eligibleBindingCount: receipt.eligibleBindingCount,
|
||||
eligiblePreservationCount: receipt.eligiblePreservationCount,
|
||||
targetConflictCount: receipt.targetConflictCount,
|
||||
adoptedLegacyTaskCount: receipt.adoptedLegacyTaskCount,
|
||||
unadaptedLegacyConfigCount: receipt.unadaptedLegacyConfigCount,
|
||||
instanceHeadDigest: head.headDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function validateTerminalBinding(
|
||||
receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
secretConfigId: string,
|
||||
): void {
|
||||
if (
|
||||
receipt.secretConfigId !== secretConfigId ||
|
||||
receipt.applicationId !== terminal.plan.applicationId ||
|
||||
receipt.applicationPlanDigest !== terminal.plan.applicationPlanDigest
|
||||
) {
|
||||
configurationError('terminal plan binding drifted');
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigTerminal {
|
||||
readonly receipt: Readonly<LocalReconciliationSecretConfigPlanReceipt>;
|
||||
readonly planPath: string;
|
||||
}
|
||||
|
||||
export function readLocalReconciliationSecretConfigTerminal(
|
||||
secretConfigRoot: string,
|
||||
secretConfigId: string,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationSecretConfigTerminal> {
|
||||
const selected = secretConfigPaths(secretConfigRoot, secretConfigId);
|
||||
validateDirectory(selected.root, uid, [0o500], 'Secret/Config plan root');
|
||||
validateDirectory(selected.staging, uid, [0o500], 'Secret/Config staging');
|
||||
validateCatalog(selected, true);
|
||||
const receipt = readReceipt(selected.receipt, uid, [0o400]);
|
||||
if (receipt.secretConfigId !== secretConfigId) {
|
||||
configurationError('Secret/Config terminal identity drifted');
|
||||
}
|
||||
validatePlanFile(selected.plan, receipt, uid, [0o400]);
|
||||
return Object.freeze({ receipt, planPath: selected.plan });
|
||||
}
|
||||
|
||||
export async function planLocalReconciliationSecretConfig(
|
||||
value: unknown,
|
||||
dependencies: LocalReconciliationSecretConfigPlanDependencies = {},
|
||||
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||
const command = normalizeLocalReconciliationSecretConfigPlanCommand(value);
|
||||
const identity = currentIdentity();
|
||||
for (const [directory, label] of [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[command.options.applicationRoot, 'applicationRoot'],
|
||||
[command.options.secretConfigRoot, 'secretConfigRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, identity.uid, label);
|
||||
}
|
||||
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||
command.options.applicationRoot,
|
||||
command.request.applicationId,
|
||||
identity.uid,
|
||||
);
|
||||
const selected = secretConfigPaths(
|
||||
command.options.secretConfigRoot,
|
||||
command.request.secretConfigId,
|
||||
);
|
||||
if (fs.existsSync(selected.receipt)) {
|
||||
validateDirectory(
|
||||
selected.root,
|
||||
identity.uid,
|
||||
[0o700, 0o500],
|
||||
'Secret/Config plan root',
|
||||
);
|
||||
validateDirectory(
|
||||
selected.staging,
|
||||
identity.uid,
|
||||
[0o700, 0o500],
|
||||
'Secret/Config staging',
|
||||
);
|
||||
validateCatalog(selected, false);
|
||||
const receipt = readReceipt(selected.receipt, identity.uid, [0o600, 0o400]);
|
||||
validateTerminalBinding(
|
||||
receipt,
|
||||
terminal,
|
||||
command.request.secretConfigId,
|
||||
);
|
||||
if (
|
||||
receipt.applicationPlanDigest !==
|
||||
command.request.expectedApplicationPlanDigest ||
|
||||
receipt.preparedHeadDigest !== command.request.expectedHeadDigest ||
|
||||
receipt.preparedAtMs !== command.request.preparedAtMs
|
||||
) {
|
||||
configurationError('terminal plan is not an exact command replay');
|
||||
}
|
||||
validatePlanFile(selected.plan, receipt, identity.uid, [0o600, 0o400]);
|
||||
let head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
terminal.intent.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
const existing = head.state === 'reconciliation_secret_config_planned';
|
||||
if (!existing) validateApplicationBinding(command, terminal, head);
|
||||
if (
|
||||
existing &&
|
||||
head.sourceRecordDigest !== receipt.secretConfigPlanDigest
|
||||
) {
|
||||
configurationError('terminal plan head digest drifted');
|
||||
}
|
||||
sealTerminal(selected, identity.uid);
|
||||
dependencies.afterTerminalSealed?.();
|
||||
head = existing ? head : advanceHead(terminal, receipt, identity.uid);
|
||||
dependencies.afterHeadAdvanced?.();
|
||||
return result(
|
||||
command.operation,
|
||||
existing ? 'existing' : 'prepared',
|
||||
receipt,
|
||||
head,
|
||||
);
|
||||
}
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
terminal.intent.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
validateApplicationBinding(command, terminal, head);
|
||||
const authority = reviewAuthority(
|
||||
command,
|
||||
terminal,
|
||||
dependencies,
|
||||
identity.uid,
|
||||
);
|
||||
ensurePrivateDirectory(
|
||||
selected.root,
|
||||
identity.uid,
|
||||
'secretConfigPlanDirectory',
|
||||
);
|
||||
ensurePrivateDirectory(
|
||||
selected.staging,
|
||||
identity.uid,
|
||||
'secretConfigPlanStaging',
|
||||
);
|
||||
validateCatalog(selected, false);
|
||||
const receipt = publishPlan(
|
||||
selected,
|
||||
command,
|
||||
terminal,
|
||||
head,
|
||||
authority,
|
||||
dependencies,
|
||||
identity.uid,
|
||||
);
|
||||
dependencies.afterPlanPublished?.();
|
||||
authority.confirmDecisionFileIdentity();
|
||||
publishExactFile(
|
||||
selected.receipt,
|
||||
receiptContents(receipt),
|
||||
0o600,
|
||||
identity.uid,
|
||||
'reconciliation secret config receipt',
|
||||
);
|
||||
dependencies.afterReceiptPublished?.();
|
||||
sealTerminal(selected, identity.uid);
|
||||
dependencies.afterTerminalSealed?.();
|
||||
const advanced = advanceHead(terminal, receipt, identity.uid);
|
||||
dependencies.afterHeadAdvanced?.();
|
||||
return result(command.operation, 'prepared', receipt, advanced);
|
||||
}
|
||||
|
||||
export async function verifyLocalReconciliationSecretConfigPlan(
|
||||
value: unknown,
|
||||
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||
const command = normalizeLocalReconciliationSecretConfigVerifyCommand(value);
|
||||
const identity = currentIdentity();
|
||||
for (const [directory, label] of [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[command.options.applicationRoot, 'applicationRoot'],
|
||||
[command.options.secretConfigRoot, 'secretConfigRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, identity.uid, label);
|
||||
}
|
||||
const selected = secretConfigPaths(
|
||||
command.options.secretConfigRoot,
|
||||
command.request.secretConfigId,
|
||||
);
|
||||
validateDirectory(selected.root, identity.uid, [0o500], 'Secret/Config plan root');
|
||||
validateDirectory(selected.staging, identity.uid, [0o500], 'Secret/Config staging');
|
||||
validateCatalog(selected, true);
|
||||
const receipt = readReceipt(selected.receipt, identity.uid, [0o400]);
|
||||
if (
|
||||
receipt.secretConfigPlanDigest !==
|
||||
command.request.expectedSecretConfigPlanDigest
|
||||
) {
|
||||
configurationError('expected Secret/Config plan digest drifted');
|
||||
}
|
||||
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||
command.options.applicationRoot,
|
||||
receipt.applicationId,
|
||||
identity.uid,
|
||||
);
|
||||
validateTerminalBinding(receipt, terminal, command.request.secretConfigId);
|
||||
validatePlanFile(selected.plan, receipt, identity.uid, [0o400]);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
command.options.deploymentRoot,
|
||||
terminal.intent.instanceId,
|
||||
identity.uid,
|
||||
);
|
||||
if (
|
||||
head.state !== 'reconciliation_secret_config_planned' ||
|
||||
head.sourceRecordDigest !== receipt.secretConfigPlanDigest
|
||||
) {
|
||||
configurationError('Secret/Config plan is detached from the instance head');
|
||||
}
|
||||
return result(command.operation, 'verified', receipt, head);
|
||||
}
|
||||
|
||||
export function planLocalReconciliationSecretConfigCommandFile(
|
||||
filePath: string,
|
||||
dependencies: LocalReconciliationSecretConfigPlanDependencies = {},
|
||||
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||
return planLocalReconciliationSecretConfig(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyLocalReconciliationSecretConfigPlanCommandFile(
|
||||
filePath: string,
|
||||
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||
return verifyLocalReconciliationSecretConfigPlan(
|
||||
readPrivateLocalCommandFile(filePath),
|
||||
);
|
||||
}
|
||||
+184
-3
@@ -24,6 +24,8 @@ 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_LINE_BYTES = 64 * 1024;
|
||||
const HASH_BUFFER_BYTES = 64 * 1024;
|
||||
const MAX_EDGE_AUTOMATION_ADOPTION_RECORDS = 128;
|
||||
const MAX_STANDALONE_AUTOMATION_ADOPTION_RECORDS = 512;
|
||||
export const MAX_EDGE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
||||
8 * 1024 * 1024;
|
||||
export const MAX_STANDALONE_LOCAL_RECONCILIATION_SECRET_CONFIG_PLAN_BYTES =
|
||||
@@ -43,7 +45,8 @@ export interface LocalReconciliationSecretConfigPlanHeader {
|
||||
readonly bundleFingerprintDigest: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly projectId: string;
|
||||
readonly tableDisposition: 'adopt_legacy' | 'retain_both';
|
||||
readonly tableDisposition: 'absent' | 'manual_external';
|
||||
readonly unadaptedLegacyConfigCount: number;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly preparedAtMs: number;
|
||||
readonly headerDigest: string;
|
||||
@@ -99,6 +102,9 @@ export interface LocalReconciliationSecretConfigPlanSummary {
|
||||
readonly eligibleBindingCount: number;
|
||||
readonly eligiblePreservationCount: number;
|
||||
readonly targetConflictCount: number;
|
||||
readonly automationAdoptionRecordCount: number;
|
||||
readonly adoptedLegacyTaskCount: number;
|
||||
readonly unadaptedLegacyConfigCount: number;
|
||||
readonly outcome: 'ready' | 'manual_required' | 'no_effect';
|
||||
}
|
||||
|
||||
@@ -110,6 +116,7 @@ export interface LocalReconciliationSecretConfigPlanFooter
|
||||
readonly legacyInventoryDigest: string;
|
||||
readonly rowSetDigest: string;
|
||||
readonly candidateSetDigest: string;
|
||||
readonly automationAdoptionSetDigest: string;
|
||||
readonly secretConfigPlanDigest: string;
|
||||
}
|
||||
|
||||
@@ -125,6 +132,7 @@ export interface LocalReconciliationSecretConfigPlanReceipt
|
||||
readonly legacyInventoryDigest: string;
|
||||
readonly rowSetDigest: string;
|
||||
readonly candidateSetDigest: string;
|
||||
readonly automationAdoptionSetDigest: string;
|
||||
readonly secretConfigPlanDigest: string;
|
||||
readonly planFileBytes: number;
|
||||
readonly planFileDigest: string;
|
||||
@@ -200,6 +208,154 @@ function bytesDigest(value: unknown, length: number, label: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function adoptionText(
|
||||
row: Readonly<Record<string, unknown>>,
|
||||
key: string,
|
||||
pattern?: RegExp,
|
||||
): string {
|
||||
const value = row[key];
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 128 ||
|
||||
(pattern !== undefined && !pattern.test(value))
|
||||
) {
|
||||
fail(`target Automation adoption ${key} drifted`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function adoptionCount(
|
||||
row: Readonly<Record<string, unknown>>,
|
||||
key: string,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = row[key];
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
fail(`target Automation adoption ${key} drifted`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function targetAutomationAdoptionProjection(
|
||||
target: DatabaseSync,
|
||||
projectId: string,
|
||||
profile: 'edge' | 'standalone',
|
||||
): Readonly<{
|
||||
recordCount: number;
|
||||
adoptedTaskCount: number;
|
||||
setDigest: string;
|
||||
}> {
|
||||
const maximumRecords =
|
||||
profile === 'edge'
|
||||
? MAX_EDGE_AUTOMATION_ADOPTION_RECORDS
|
||||
: MAX_STANDALONE_AUTOMATION_ADOPTION_RECORDS;
|
||||
const hash = createHash('sha256').update(
|
||||
'qinglong3.local-reconciliation-secret-config-automation-adoption-set.v1\0',
|
||||
);
|
||||
let recordCount = 0;
|
||||
let adoptedTaskCount = 0;
|
||||
try {
|
||||
const rows = target
|
||||
.prepare(
|
||||
`SELECT "mutation_id" AS "mutationId",
|
||||
"decision_id" AS "decisionId",
|
||||
"plan_digest" AS "planDigest",
|
||||
"inventory_digest" AS "inventoryDigest",
|
||||
"decision_digest" AS "decisionDigest",
|
||||
"receipt_digest" AS "receiptDigest",
|
||||
"authorization_file_digest" AS "authorizationFileDigest",
|
||||
"publication_digest" AS "publicationDigest",
|
||||
"row_count" AS "rowCount",
|
||||
"adopted_task_count" AS "adoptedTaskCount",
|
||||
"adopted_trigger_count" AS "adoptedTriggerCount",
|
||||
"skipped_count" AS "skippedCount",
|
||||
"audit_event_id" AS "auditEventId",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
FROM "QingLong3LegacyAdoptions"
|
||||
WHERE "project_id" = ?
|
||||
ORDER BY "created_at_ms" ASC, "mutation_id" ASC`,
|
||||
)
|
||||
.iterate(projectId) as Iterable<Readonly<Record<string, unknown>>>;
|
||||
for (const row of rows) {
|
||||
recordCount += 1;
|
||||
if (recordCount > maximumRecords) {
|
||||
fail('target Automation adoption projection exceeds profile budget');
|
||||
}
|
||||
const rowCount = adoptionCount(row, 'rowCount', 100_000);
|
||||
const selectedAdoptedTaskCount = adoptionCount(
|
||||
row,
|
||||
'adoptedTaskCount',
|
||||
rowCount,
|
||||
);
|
||||
const skippedCount = adoptionCount(row, 'skippedCount', rowCount);
|
||||
if (selectedAdoptedTaskCount + skippedCount !== rowCount) {
|
||||
fail('target Automation adoption row accounting drifted');
|
||||
}
|
||||
adoptedTaskCount += selectedAdoptedTaskCount;
|
||||
if (!Number.isSafeInteger(adoptedTaskCount)) {
|
||||
fail('target Automation adoption task count overflowed');
|
||||
}
|
||||
const payload = Object.freeze({
|
||||
mutationId: adoptionText(row, 'mutationId', UUID_V4_PATTERN),
|
||||
decisionId: adoptionText(
|
||||
row,
|
||||
'decisionId',
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
),
|
||||
planDigest: adoptionText(row, 'planDigest', DIGEST_PATTERN),
|
||||
inventoryDigest: adoptionText(
|
||||
row,
|
||||
'inventoryDigest',
|
||||
DIGEST_PATTERN,
|
||||
),
|
||||
decisionDigest: adoptionText(row, 'decisionDigest', DIGEST_PATTERN),
|
||||
receiptDigest: adoptionText(row, 'receiptDigest', DIGEST_PATTERN),
|
||||
authorizationFileDigest: adoptionText(
|
||||
row,
|
||||
'authorizationFileDigest',
|
||||
DIGEST_PATTERN,
|
||||
),
|
||||
publicationDigest: adoptionText(
|
||||
row,
|
||||
'publicationDigest',
|
||||
DIGEST_PATTERN,
|
||||
),
|
||||
rowCount,
|
||||
adoptedTaskCount: selectedAdoptedTaskCount,
|
||||
adoptedTriggerCount: adoptionCount(
|
||||
row,
|
||||
'adoptedTriggerCount',
|
||||
500_000,
|
||||
),
|
||||
skippedCount,
|
||||
auditEventId: adoptionText(row, 'auditEventId', UUID_V4_PATTERN),
|
||||
createdAtMs: adoptionCount(
|
||||
row,
|
||||
'createdAtMs',
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
});
|
||||
if (payload.auditEventId !== payload.mutationId) {
|
||||
fail('target Automation adoption audit binding drifted');
|
||||
}
|
||||
hash.update('\0').update(JSON.stringify(payload));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return fail('target Automation adoption projection is unavailable', error);
|
||||
}
|
||||
return Object.freeze({
|
||||
recordCount,
|
||||
adoptedTaskCount,
|
||||
setDigest: hash.digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
function targetSecret(
|
||||
target: DatabaseSync,
|
||||
projectId: string,
|
||||
@@ -402,6 +558,11 @@ export function writeLocalReconciliationSecretConfigPlan(
|
||||
append(candidate, 'candidate');
|
||||
},
|
||||
});
|
||||
const automationAdoption = targetAutomationAdoptionProjection(
|
||||
options.target,
|
||||
header.projectId,
|
||||
header.profile,
|
||||
);
|
||||
const summary: LocalReconciliationSecretConfigPlanSummary = Object.freeze({
|
||||
tableState: inventory.tableState,
|
||||
rowCount: inventory.rowCount,
|
||||
@@ -415,10 +576,17 @@ export function writeLocalReconciliationSecretConfigPlan(
|
||||
eligibleBindingCount,
|
||||
eligiblePreservationCount,
|
||||
targetConflictCount,
|
||||
automationAdoptionRecordCount: automationAdoption.recordCount,
|
||||
adoptedLegacyTaskCount: automationAdoption.adoptedTaskCount,
|
||||
unadaptedLegacyConfigCount: header.unadaptedLegacyConfigCount,
|
||||
outcome:
|
||||
inventory.tableState === 'absent' || inventory.rowCount === 0
|
||||
(inventory.tableState === 'absent' || inventory.rowCount === 0) &&
|
||||
header.unadaptedLegacyConfigCount === 0
|
||||
? ('no_effect' as const)
|
||||
: !inventory.mutationReady || targetConflictCount > 0
|
||||
: !inventory.mutationReady ||
|
||||
targetConflictCount > 0 ||
|
||||
header.unadaptedLegacyConfigCount > 0 ||
|
||||
(eligibleBindingCount > 0 && automationAdoption.adoptedTaskCount < 1)
|
||||
? ('manual_required' as const)
|
||||
: ('ready' as const),
|
||||
});
|
||||
@@ -430,6 +598,7 @@ export function writeLocalReconciliationSecretConfigPlan(
|
||||
legacyInventoryDigest: inventory.inventoryDigest,
|
||||
rowSetDigest: rowHash.digest('hex'),
|
||||
candidateSetDigest: candidateHash.digest('hex'),
|
||||
automationAdoptionSetDigest: automationAdoption.setDigest,
|
||||
});
|
||||
const footer = Object.freeze({
|
||||
...footerPayload,
|
||||
@@ -464,6 +633,7 @@ export function buildLocalReconciliationSecretConfigPlanReceipt(
|
||||
legacyInventoryDigest: footer.legacyInventoryDigest,
|
||||
rowSetDigest: footer.rowSetDigest,
|
||||
candidateSetDigest: footer.candidateSetDigest,
|
||||
automationAdoptionSetDigest: footer.automationAdoptionSetDigest,
|
||||
secretConfigPlanDigest: footer.secretConfigPlanDigest,
|
||||
planFileBytes,
|
||||
planFileDigest,
|
||||
@@ -479,6 +649,9 @@ export function buildLocalReconciliationSecretConfigPlanReceipt(
|
||||
eligibleBindingCount: footer.eligibleBindingCount,
|
||||
eligiblePreservationCount: footer.eligiblePreservationCount,
|
||||
targetConflictCount: footer.targetConflictCount,
|
||||
automationAdoptionRecordCount: footer.automationAdoptionRecordCount,
|
||||
adoptedLegacyTaskCount: footer.adoptedLegacyTaskCount,
|
||||
unadaptedLegacyConfigCount: footer.unadaptedLegacyConfigCount,
|
||||
outcome: footer.outcome,
|
||||
preparedAtMs: header.preparedAtMs,
|
||||
});
|
||||
@@ -493,8 +666,11 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
||||
[
|
||||
'activeGroupCount',
|
||||
'activeRowCount',
|
||||
'adoptedLegacyTaskCount',
|
||||
'applicationId',
|
||||
'applicationPlanDigest',
|
||||
'automationAdoptionRecordCount',
|
||||
'automationAdoptionSetDigest',
|
||||
'bindingReadyCount',
|
||||
'candidateSetDigest',
|
||||
'disabledRowCount',
|
||||
@@ -519,6 +695,7 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
||||
'state',
|
||||
'tableState',
|
||||
'targetConflictCount',
|
||||
'unadaptedLegacyConfigCount',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
@@ -537,6 +714,7 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
||||
receipt.legacyInventoryDigest,
|
||||
receipt.rowSetDigest,
|
||||
receipt.candidateSetDigest,
|
||||
receipt.automationAdoptionSetDigest,
|
||||
receipt.secretConfigPlanDigest,
|
||||
receipt.planFileDigest,
|
||||
receiptDigest,
|
||||
@@ -556,6 +734,9 @@ export function normalizeLocalReconciliationSecretConfigPlanReceipt(
|
||||
receipt.eligibleBindingCount,
|
||||
receipt.eligiblePreservationCount,
|
||||
receipt.targetConflictCount,
|
||||
receipt.automationAdoptionRecordCount,
|
||||
receipt.adoptedLegacyTaskCount,
|
||||
receipt.unadaptedLegacyConfigCount,
|
||||
receipt.planFileBytes,
|
||||
receipt.preparedAtMs,
|
||||
].every((count) => Number.isSafeInteger(count) && (count as number) >= 0) ||
|
||||
|
||||
+5
-1
@@ -771,7 +771,11 @@ function replayResult(
|
||||
head.state === 'reconciliation_application_planned' ||
|
||||
head.state === 'reconciliation_automation_planned' ||
|
||||
head.state === 'reconciliation_automation_decision_prepared' ||
|
||||
head.state === 'reconciliation_automation_reviewed');
|
||||
head.state === 'reconciliation_automation_reviewed' ||
|
||||
head.state === 'reconciliation_automation_apply_prepared' ||
|
||||
head.state === 'reconciliation_automation_applied' ||
|
||||
head.state === 'reconciliation_automation_rolled_back' ||
|
||||
head.state === 'reconciliation_secret_config_planned');
|
||||
const completionRestartPendingHead =
|
||||
(record.state === 'target_active' || record.state === 'manual_required') &&
|
||||
completionFence !== undefined &&
|
||||
|
||||
Reference in New Issue
Block a user