mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add secret config completion evidence
This commit is contained in:
@@ -501,7 +501,8 @@ export function advanceLocalCutoverInstanceHead(
|
||||
current.state === 'reconciliation_secret_config_applied') ||
|
||||
(state === 'reconciliation_completed' &&
|
||||
(current.state === 'reconciliation_application_planned' ||
|
||||
current.state === 'reconciliation_automation_applied')) ||
|
||||
current.state === 'reconciliation_automation_applied' ||
|
||||
current.state === 'reconciliation_secret_config_applied')) ||
|
||||
(state === 'rollback_prepared' && current.state === 'target_stopped') ||
|
||||
(state === 'legacy_restart_requested' &&
|
||||
current.state === 'rollback_prepared') ||
|
||||
|
||||
+72
@@ -570,6 +570,78 @@ export function validateLocalReconciliationSecretConfigAppliedStorage(
|
||||
validateBackup(selected, intent, uid, [0o400]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the rollback copy only after the cross-domain completion head is
|
||||
* durable. Encrypted material and receipts remain sealed as audit evidence.
|
||||
* The partially collected layout is accepted so a response loss can replay.
|
||||
*/
|
||||
export function collectLocalReconciliationSecretConfigCompletedStorage(
|
||||
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o700, 0o500], 'backup root');
|
||||
directoryMode(
|
||||
selected.rollbackRoot,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'rollback work root',
|
||||
);
|
||||
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||
if (fs.readdirSync(selected.rollbackRoot).length !== 0) {
|
||||
fail('rollback work root must be empty');
|
||||
}
|
||||
readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||
readLocalReconciliationSecretConfigApplyReceipt(selected, uid);
|
||||
readLocalReconciliationSecretConfigMaterials(
|
||||
selected,
|
||||
intent.profile,
|
||||
uid,
|
||||
intent.material,
|
||||
);
|
||||
if (fs.existsSync(selected.backup)) {
|
||||
validateBackup(selected, intent, uid, [0o400]);
|
||||
if ((fs.statSync(selected.backupRoot).mode & 0o777) !== 0o700) {
|
||||
fs.chmodSync(selected.backupRoot, 0o700);
|
||||
syncDirectory(selected.root);
|
||||
}
|
||||
unlinkIfPresent(selected.backup);
|
||||
}
|
||||
sealDirectory(selected.backupRoot, uid, 'backup root');
|
||||
sealDirectory(selected.rollbackRoot, uid, 'rollback work root');
|
||||
validateLocalReconciliationSecretConfigCompletedStorage(
|
||||
selected,
|
||||
intent,
|
||||
uid,
|
||||
);
|
||||
}
|
||||
|
||||
export function validateLocalReconciliationSecretConfigCompletedStorage(
|
||||
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||
directoryMode(selected.rollbackRoot, uid, [0o500], 'rollback work root');
|
||||
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||
if (fs.readdirSync(selected.backupRoot).length !== 0) {
|
||||
fail('backup root must be empty');
|
||||
}
|
||||
if (fs.readdirSync(selected.rollbackRoot).length !== 0) {
|
||||
fail('rollback work root must be empty');
|
||||
}
|
||||
readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||
readLocalReconciliationSecretConfigApplyReceipt(selected, uid);
|
||||
readLocalReconciliationSecretConfigMaterials(
|
||||
selected,
|
||||
intent.profile,
|
||||
uid,
|
||||
intent.material,
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareLocalReconciliationSecretConfigRollbackSource(
|
||||
selected: Readonly<LocalReconciliationSecretConfigApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationSecretConfigApplyIntent>,
|
||||
|
||||
@@ -17,11 +17,19 @@ export interface LocalReconciliationCompletionAutomationOptions {
|
||||
readonly targetDatabasePath: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionSecretConfigOptions {
|
||||
readonly secretConfigRoot: string;
|
||||
readonly secretConfigDecisionRoot: string;
|
||||
readonly secretConfigApplyRoot: string;
|
||||
readonly targetDatabasePath: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly applicationRoot: string;
|
||||
readonly completionRoot: string;
|
||||
readonly automation: Readonly<LocalReconciliationCompletionAutomationOptions> | null;
|
||||
readonly secretConfig: Readonly<LocalReconciliationCompletionSecretConfigOptions> | null;
|
||||
readonly runHistory: Readonly<LocalReconciliationCompletionRunHistoryOptions> | null;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
@@ -37,13 +45,19 @@ export interface LocalReconciliationCompletionAutomationBinding {
|
||||
readonly expectedApplyDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionSecretConfigBinding {
|
||||
readonly secretConfigId: string;
|
||||
readonly decisionId: string;
|
||||
readonly expectedApplyDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionRunHistoryBinding {
|
||||
readonly preservationId: string;
|
||||
readonly expectedPreservationDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompleteCommand {
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly schemaVersion: 1 | 2 | 3;
|
||||
readonly operation: 'local.deployment.reconciliation.complete';
|
||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||
readonly request: Readonly<{
|
||||
@@ -52,13 +66,14 @@ export interface LocalReconciliationCompleteCommand {
|
||||
expectedApplicationPlanDigest: string;
|
||||
expectedHeadDigest: string;
|
||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||
secretConfig: Readonly<LocalReconciliationCompletionSecretConfigBinding> | null;
|
||||
runHistory: Readonly<LocalReconciliationCompletionRunHistoryBinding> | null;
|
||||
completedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionVerifyCommand {
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly schemaVersion: 1 | 2 | 3;
|
||||
readonly operation: 'local.deployment.reconciliation.complete.verify';
|
||||
readonly options: Readonly<LocalReconciliationCompletionOptions>;
|
||||
readonly request: Readonly<{
|
||||
@@ -66,6 +81,7 @@ export interface LocalReconciliationCompletionVerifyCommand {
|
||||
applicationId: string;
|
||||
expectedCompletionDigest: string;
|
||||
automation: Readonly<LocalReconciliationCompletionAutomationBinding> | null;
|
||||
secretConfig: Readonly<LocalReconciliationCompletionSecretConfigBinding> | null;
|
||||
runHistory: Readonly<LocalReconciliationCompletionRunHistoryBinding> | null;
|
||||
}>;
|
||||
}
|
||||
@@ -81,7 +97,7 @@ export interface LocalReconciliationCompletionResult {
|
||||
readonly applicationId: string;
|
||||
readonly completionDigest: string;
|
||||
readonly domainCount: 8;
|
||||
readonly adapterCount: 0 | 1 | 2;
|
||||
readonly adapterCount: 0 | 1 | 2 | 3;
|
||||
readonly instanceHeadDigest: string;
|
||||
}
|
||||
|
||||
@@ -180,9 +196,41 @@ function normalizeAutomationOptions(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSecretConfigOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionSecretConfigOptions> | null {
|
||||
if (value === null) return null;
|
||||
const selected = record(value, 'secret config options');
|
||||
exact(
|
||||
selected,
|
||||
[
|
||||
'secretConfigApplyRoot',
|
||||
'secretConfigDecisionRoot',
|
||||
'secretConfigRoot',
|
||||
'targetDatabasePath',
|
||||
],
|
||||
'secret config options',
|
||||
);
|
||||
return Object.freeze({
|
||||
secretConfigRoot: safePath(selected.secretConfigRoot, 'secretConfigRoot'),
|
||||
secretConfigDecisionRoot: safePath(
|
||||
selected.secretConfigDecisionRoot,
|
||||
'secretConfigDecisionRoot',
|
||||
),
|
||||
secretConfigApplyRoot: safePath(
|
||||
selected.secretConfigApplyRoot,
|
||||
'secretConfigApplyRoot',
|
||||
),
|
||||
targetDatabasePath: safePath(
|
||||
selected.targetDatabasePath,
|
||||
'targetDatabasePath',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
schemaVersion: 1 | 2,
|
||||
schemaVersion: 1 | 2 | 3,
|
||||
): Readonly<LocalReconciliationCompletionOptions> {
|
||||
const selected = record(value, 'options');
|
||||
exact(
|
||||
@@ -195,6 +243,15 @@ function normalizeOptions(
|
||||
'completionRoot',
|
||||
'deploymentRoot',
|
||||
]
|
||||
: schemaVersion === 2
|
||||
? [
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
'automation',
|
||||
'completionRoot',
|
||||
'deploymentRoot',
|
||||
'runHistory',
|
||||
]
|
||||
: [
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
@@ -202,6 +259,7 @@ function normalizeOptions(
|
||||
'completionRoot',
|
||||
'deploymentRoot',
|
||||
'runHistory',
|
||||
'secretConfig',
|
||||
],
|
||||
'options',
|
||||
);
|
||||
@@ -212,8 +270,14 @@ function normalizeOptions(
|
||||
fail('command identity is invalid');
|
||||
}
|
||||
const automation = normalizeAutomationOptions(selected.automation);
|
||||
const secretConfig =
|
||||
schemaVersion === 3
|
||||
? normalizeSecretConfigOptions(selected.secretConfig)
|
||||
: null;
|
||||
const runHistory =
|
||||
schemaVersion === 1
|
||||
? null
|
||||
: schemaVersion === 3 && selected.runHistory === null
|
||||
? null
|
||||
: normalizeRunHistoryOptions(selected.runHistory);
|
||||
const normalized = Object.freeze({
|
||||
@@ -221,6 +285,7 @@ function normalizeOptions(
|
||||
applicationRoot: safePath(selected.applicationRoot, 'applicationRoot'),
|
||||
completionRoot: safePath(selected.completionRoot, 'completionRoot'),
|
||||
automation,
|
||||
secretConfig,
|
||||
runHistory,
|
||||
allowRootService: selected.allowRootService,
|
||||
}) as Readonly<LocalReconciliationCompletionOptions>;
|
||||
@@ -235,6 +300,13 @@ function normalizeOptions(
|
||||
automation.automationDecisionRoot,
|
||||
automation.automationApplyRoot,
|
||||
]),
|
||||
...(secretConfig === null
|
||||
? []
|
||||
: [
|
||||
secretConfig.secretConfigRoot,
|
||||
secretConfig.secretConfigDecisionRoot,
|
||||
secretConfig.secretConfigApplyRoot,
|
||||
]),
|
||||
...(runHistory === null ? [] : [runHistory.runHistoryRoot]),
|
||||
];
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
@@ -257,6 +329,23 @@ function normalizeOptions(
|
||||
) {
|
||||
fail('targetDatabasePath overlaps an authority root');
|
||||
}
|
||||
if (
|
||||
secretConfig !== null &&
|
||||
roots.some(
|
||||
(root) =>
|
||||
overlaps(root, secretConfig.targetDatabasePath) ||
|
||||
overlaps(secretConfig.targetDatabasePath, root),
|
||||
)
|
||||
) {
|
||||
fail('targetDatabasePath overlaps an authority root');
|
||||
}
|
||||
if (
|
||||
automation !== null &&
|
||||
secretConfig !== null &&
|
||||
automation.targetDatabasePath !== secretConfig.targetDatabasePath
|
||||
) {
|
||||
fail('adapter targetDatabasePath values differ');
|
||||
}
|
||||
if (
|
||||
runHistory !== null &&
|
||||
roots.some(
|
||||
@@ -305,6 +394,30 @@ function normalizeAutomationBinding(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSecretConfigBinding(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionSecretConfigBinding> | null {
|
||||
if (value === null) return null;
|
||||
const selected = record(value, 'secret config binding');
|
||||
exact(
|
||||
selected,
|
||||
['decisionId', 'expectedApplyDigest', 'secretConfigId'],
|
||||
'secret config binding',
|
||||
);
|
||||
return Object.freeze({
|
||||
secretConfigId: identifier(
|
||||
selected.secretConfigId,
|
||||
UUID_V4,
|
||||
'secretConfigId',
|
||||
),
|
||||
decisionId: identifier(selected.decisionId, UUID_V7, 'decisionId'),
|
||||
expectedApplyDigest: digest(
|
||||
selected.expectedApplyDigest,
|
||||
'expectedApplyDigest',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRunHistoryBinding(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationCompletionRunHistoryBinding> {
|
||||
@@ -335,7 +448,9 @@ function command(value: unknown, operation: string) {
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
(selected.schemaVersion !== 1 && selected.schemaVersion !== 2) ||
|
||||
(selected.schemaVersion !== 1 &&
|
||||
selected.schemaVersion !== 2 &&
|
||||
selected.schemaVersion !== 3) ||
|
||||
selected.operation !== operation
|
||||
) {
|
||||
fail('command version or operation is invalid');
|
||||
@@ -362,6 +477,16 @@ export function normalizeLocalReconciliationCompleteCommand(
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
]
|
||||
: selected.schemaVersion === 2
|
||||
? [
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completedAtMs',
|
||||
'completionId',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
'runHistory',
|
||||
]
|
||||
: [
|
||||
'applicationId',
|
||||
'automation',
|
||||
@@ -370,6 +495,7 @@ export function normalizeLocalReconciliationCompleteCommand(
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
'runHistory',
|
||||
'secretConfig',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
@@ -403,8 +529,14 @@ export function normalizeLocalReconciliationCompleteCommand(
|
||||
'expectedHeadDigest',
|
||||
),
|
||||
automation: normalizeAutomationBinding(selected.request.automation),
|
||||
secretConfig:
|
||||
selected.schemaVersion === 3
|
||||
? normalizeSecretConfigBinding(selected.request.secretConfig)
|
||||
: null,
|
||||
runHistory:
|
||||
selected.schemaVersion === 1
|
||||
? null
|
||||
: selected.schemaVersion === 3 && selected.request.runHistory === null
|
||||
? null
|
||||
: normalizeRunHistoryBinding(selected.request.runHistory),
|
||||
completedAtMs: selected.request.completedAtMs as number,
|
||||
@@ -428,12 +560,21 @@ export function normalizeLocalReconciliationCompletionVerifyCommand(
|
||||
'completionId',
|
||||
'expectedCompletionDigest',
|
||||
]
|
||||
: selected.schemaVersion === 2
|
||||
? [
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completionId',
|
||||
'expectedCompletionDigest',
|
||||
'runHistory',
|
||||
]
|
||||
: [
|
||||
'applicationId',
|
||||
'automation',
|
||||
'completionId',
|
||||
'expectedCompletionDigest',
|
||||
'runHistory',
|
||||
'secretConfig',
|
||||
],
|
||||
'request',
|
||||
);
|
||||
@@ -457,8 +598,14 @@ export function normalizeLocalReconciliationCompletionVerifyCommand(
|
||||
'expectedCompletionDigest',
|
||||
),
|
||||
automation: normalizeAutomationBinding(selected.request.automation),
|
||||
secretConfig:
|
||||
selected.schemaVersion === 3
|
||||
? normalizeSecretConfigBinding(selected.request.secretConfig)
|
||||
: null,
|
||||
runHistory:
|
||||
selected.schemaVersion === 1
|
||||
? null
|
||||
: selected.schemaVersion === 3 && selected.request.runHistory === null
|
||||
? null
|
||||
: normalizeRunHistoryBinding(selected.request.runHistory),
|
||||
}),
|
||||
|
||||
+252
-15
@@ -33,6 +33,20 @@ import type {
|
||||
LocalReconciliationAutomationApplyReceipt,
|
||||
} from '../application/automation/applyEvidence';
|
||||
import { readLocalReconciliationAutomationDecisionTerminal } from '../application/automation/decisionCoordinator';
|
||||
import { readLocalReconciliationSecretConfigDecisionTerminal } from '../application/secret-and-config/decisionCoordinator';
|
||||
import type {
|
||||
LocalReconciliationSecretConfigApplyIntent,
|
||||
LocalReconciliationSecretConfigApplyReceipt,
|
||||
} from '../application/secret-and-config/application/evidence';
|
||||
import {
|
||||
collectLocalReconciliationSecretConfigCompletedStorage,
|
||||
localReconciliationSecretConfigApplyPaths,
|
||||
readLocalReconciliationSecretConfigApplyIntent,
|
||||
readLocalReconciliationSecretConfigApplyReceipt,
|
||||
validateLocalReconciliationSecretConfigAppliedStorage,
|
||||
validateLocalReconciliationSecretConfigApplyCatalog,
|
||||
validateLocalReconciliationSecretConfigCompletedStorage,
|
||||
} from '../application/secret-and-config/application/storage';
|
||||
import {
|
||||
readLocalReconciliationRunHistoryTerminal,
|
||||
type LocalReconciliationRunHistoryDependencies,
|
||||
@@ -66,6 +80,14 @@ interface AutomationProof {
|
||||
readonly intent: Readonly<LocalReconciliationAutomationApplyIntent>;
|
||||
readonly receipt: Readonly<LocalReconciliationAutomationApplyReceipt>;
|
||||
readonly paths: ReturnType<typeof localReconciliationAutomationApplyPaths>;
|
||||
readonly storageState: 'applied' | 'completed';
|
||||
}
|
||||
|
||||
interface SecretConfigProof {
|
||||
readonly intent: Readonly<LocalReconciliationSecretConfigApplyIntent>;
|
||||
readonly receipt: Readonly<LocalReconciliationSecretConfigApplyReceipt>;
|
||||
readonly paths: ReturnType<typeof localReconciliationSecretConfigApplyPaths>;
|
||||
readonly storageState: 'applied' | 'completed';
|
||||
}
|
||||
|
||||
interface RunHistoryProof {
|
||||
@@ -79,6 +101,7 @@ export interface LocalReconciliationCompletionDependencies
|
||||
readonly afterTerminalSealed?: () => void;
|
||||
readonly afterHeadAdvanced?: () => void;
|
||||
readonly afterBackupCollected?: () => void;
|
||||
readonly afterSecretConfigBackupCollected?: () => void;
|
||||
}
|
||||
|
||||
async function runHistoryProof(
|
||||
@@ -444,6 +467,134 @@ async function automationProof(
|
||||
) {
|
||||
fail('automation apply evidence is detached');
|
||||
}
|
||||
if (command.request.secretConfig === null) {
|
||||
const current = await (
|
||||
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||
)({
|
||||
databasePath: options.targetDatabasePath,
|
||||
profile: intent.profile,
|
||||
});
|
||||
if (current.sha256 !== receipt.targetAfter.sha256) {
|
||||
fail('automation target drifted after apply');
|
||||
}
|
||||
}
|
||||
const storageState = fs.existsSync(selected.backup)
|
||||
? ('applied' as const)
|
||||
: ('completed' as const);
|
||||
if (storageState === 'applied') {
|
||||
validateLocalReconciliationAutomationAppliedStorage(selected, intent, uid);
|
||||
} else {
|
||||
validateLocalReconciliationAutomationCompletedStorage(selected, uid);
|
||||
}
|
||||
return Object.freeze({
|
||||
intent,
|
||||
receipt,
|
||||
paths: selected,
|
||||
storageState,
|
||||
});
|
||||
}
|
||||
|
||||
async function secretConfigProof(
|
||||
command: Readonly<LocalReconciliationCompleteCommand>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
automation: Readonly<AutomationProof> | null,
|
||||
uid: number,
|
||||
dependencies: LocalReconciliationCompletionDependencies,
|
||||
): Promise<Readonly<SecretConfigProof> | null> {
|
||||
const secretConfigDomain = terminal.plan.domains.find(
|
||||
(domain) => domain.domain === 'secret_and_config',
|
||||
);
|
||||
if (!secretConfigDomain) fail('secret and config domain is absent');
|
||||
if (secretConfigDomain.action === 'no_effect') {
|
||||
if (
|
||||
command.options.secretConfig !== null ||
|
||||
command.request.secretConfig !== null
|
||||
) {
|
||||
fail('no-effect completion must not carry secret config authority');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
secretConfigDomain.action === 'manual_external' &&
|
||||
command.options.secretConfig === null &&
|
||||
command.request.secretConfig === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
secretConfigDomain.action !== 'manual_external' ||
|
||||
command.options.secretConfig === null ||
|
||||
command.request.secretConfig === null
|
||||
) {
|
||||
fail('secret and config domain is not terminally provable');
|
||||
}
|
||||
const options = command.options.secretConfig;
|
||||
const binding = command.request.secretConfig;
|
||||
for (const [directory, label] of [
|
||||
[options.secretConfigRoot, 'secretConfigRoot'],
|
||||
[options.secretConfigDecisionRoot, 'secretConfigDecisionRoot'],
|
||||
[options.secretConfigApplyRoot, 'secretConfigApplyRoot'],
|
||||
] as const) {
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
}
|
||||
const decision = await readLocalReconciliationSecretConfigDecisionTerminal(
|
||||
{
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
applicationRoot: command.options.applicationRoot,
|
||||
secretConfigRoot: options.secretConfigRoot,
|
||||
secretConfigDecisionRoot: options.secretConfigDecisionRoot,
|
||||
allowRootService: command.options.allowRootService,
|
||||
},
|
||||
binding.secretConfigId,
|
||||
uid,
|
||||
[
|
||||
'reconciliation_secret_config_applied',
|
||||
'reconciliation_secret_config_rolled_back',
|
||||
'reconciliation_completed',
|
||||
],
|
||||
);
|
||||
if (
|
||||
decision.receipt.decisionId !== binding.decisionId ||
|
||||
decision.receipt.outcome !== 'ready' ||
|
||||
decision.context.application.plan.applicationPlanDigest !==
|
||||
terminal.plan.applicationPlanDigest
|
||||
) {
|
||||
fail('secret config decision is detached from application authority');
|
||||
}
|
||||
const selected = localReconciliationSecretConfigApplyPaths(
|
||||
options.secretConfigApplyRoot,
|
||||
binding.secretConfigId,
|
||||
);
|
||||
validateLocalReconciliationSecretConfigApplyCatalog(selected);
|
||||
const intent = readLocalReconciliationSecretConfigApplyIntent(selected, uid);
|
||||
const receipt = readLocalReconciliationSecretConfigApplyReceipt(
|
||||
selected,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
intent.command.options.deploymentRoot !== command.options.deploymentRoot ||
|
||||
intent.command.options.applicationRoot !==
|
||||
command.options.applicationRoot ||
|
||||
intent.command.options.secretConfigRoot !== options.secretConfigRoot ||
|
||||
intent.command.options.secretConfigDecisionRoot !==
|
||||
options.secretConfigDecisionRoot ||
|
||||
intent.command.options.secretConfigApplyRoot !==
|
||||
options.secretConfigApplyRoot ||
|
||||
intent.command.options.targetDatabasePath !== options.targetDatabasePath ||
|
||||
intent.command.request.secretConfigId !== binding.secretConfigId ||
|
||||
intent.command.request.decisionId !== binding.decisionId ||
|
||||
intent.command.request.expectedDecisionDigest !==
|
||||
decision.receipt.decisionDigest ||
|
||||
receipt.secretConfigId !== binding.secretConfigId ||
|
||||
receipt.decisionId !== binding.decisionId ||
|
||||
receipt.applyDigest !== binding.expectedApplyDigest ||
|
||||
receipt.preparationDigest !== intent.preparationDigest ||
|
||||
(automation !== null &&
|
||||
intent.backup.sha256 !== automation.receipt.targetAfter.sha256) ||
|
||||
fs.existsSync(selected.rollbackReceipt)
|
||||
) {
|
||||
fail('secret config apply evidence is detached');
|
||||
}
|
||||
const current = await (
|
||||
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||
)({
|
||||
@@ -451,14 +602,36 @@ async function automationProof(
|
||||
profile: intent.profile,
|
||||
});
|
||||
if (current.sha256 !== receipt.targetAfter.sha256) {
|
||||
fail('automation target drifted after apply');
|
||||
fail('secret config target drifted after apply');
|
||||
}
|
||||
return Object.freeze({ intent, receipt, paths: selected });
|
||||
const storageState = fs.existsSync(selected.backup)
|
||||
? ('applied' as const)
|
||||
: ('completed' as const);
|
||||
if (storageState === 'applied') {
|
||||
validateLocalReconciliationSecretConfigAppliedStorage(
|
||||
selected,
|
||||
intent,
|
||||
uid,
|
||||
);
|
||||
} else {
|
||||
validateLocalReconciliationSecretConfigCompletedStorage(
|
||||
selected,
|
||||
intent,
|
||||
uid,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
intent,
|
||||
receipt,
|
||||
paths: selected,
|
||||
storageState,
|
||||
});
|
||||
}
|
||||
|
||||
function domainEvidence(
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
automation: Readonly<AutomationProof> | null,
|
||||
secretConfig: Readonly<SecretConfigProof> | null,
|
||||
runHistory: Readonly<RunHistoryProof> | null,
|
||||
): readonly Readonly<LocalReconciliationCompletionDomainEvidence>[] {
|
||||
return Object.freeze(
|
||||
@@ -483,6 +656,18 @@ function domainEvidence(
|
||||
evidenceDigest: automation.receipt.applyDigest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
domain.domain === 'secret_and_config' &&
|
||||
domain.action === 'manual_external' &&
|
||||
secretConfig !== null
|
||||
) {
|
||||
return Object.freeze({
|
||||
domain: domain.domain,
|
||||
action: 'adapter_required' as const,
|
||||
evidenceKind: 'secret_config_application' as const,
|
||||
evidenceDigest: secretConfig.receipt.applyDigest,
|
||||
});
|
||||
}
|
||||
if (
|
||||
domain.domain === 'run_history' &&
|
||||
domain.action === 'adapter_required' &&
|
||||
@@ -571,13 +756,20 @@ function assertSourceHead(
|
||||
head: Readonly<LocalCutoverInstanceHead>,
|
||||
expectedHeadDigest: string,
|
||||
automation: Readonly<AutomationProof> | null,
|
||||
secretConfig: Readonly<SecretConfigProof> | null,
|
||||
): void {
|
||||
const expectedState =
|
||||
automation === null
|
||||
secretConfig !== null
|
||||
? 'reconciliation_secret_config_applied'
|
||||
: automation === null
|
||||
? 'reconciliation_application_planned'
|
||||
: 'reconciliation_automation_applied';
|
||||
const expectedSource =
|
||||
automation === null ? undefined : automation.receipt.applyDigest;
|
||||
secretConfig !== null
|
||||
? secretConfig.receipt.applyDigest
|
||||
: automation === null
|
||||
? undefined
|
||||
: automation.receipt.applyDigest;
|
||||
if (
|
||||
head.headDigest !== expectedHeadDigest ||
|
||||
head.state !== expectedState ||
|
||||
@@ -618,13 +810,25 @@ export async function completeLocalReconciliation(
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const secretConfig = await secretConfigProof(
|
||||
command,
|
||||
terminal,
|
||||
automation,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const runHistory = await runHistoryProof(
|
||||
command,
|
||||
terminal,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation, runHistory);
|
||||
const domains = domainEvidence(
|
||||
terminal,
|
||||
automation,
|
||||
secretConfig,
|
||||
runHistory,
|
||||
);
|
||||
const selected = ensureCompletionDirectory(
|
||||
command.options.completionRoot,
|
||||
command.request.completionId,
|
||||
@@ -654,13 +858,19 @@ export async function completeLocalReconciliation(
|
||||
fail('completion command is not an exact replay');
|
||||
}
|
||||
} else {
|
||||
assertSourceHead(head, command.request.expectedHeadDigest, automation);
|
||||
assertSourceHead(
|
||||
head,
|
||||
command.request.expectedHeadDigest,
|
||||
automation,
|
||||
secretConfig,
|
||||
);
|
||||
const adapterCount = domains.filter(
|
||||
(domain) => domain.action === 'adapter_required',
|
||||
).length as 0 | 1 | 2;
|
||||
).length as 0 | 1 | 2 | 3;
|
||||
const latestEvidenceAtMs = Math.max(
|
||||
terminal.plan.committedAtMs,
|
||||
automation?.receipt.appliedAtMs ?? 0,
|
||||
secretConfig?.receipt.appliedAtMs ?? 0,
|
||||
runHistory?.receipt.preservedAtMs ?? 0,
|
||||
);
|
||||
if (command.request.completedAtMs < latestEvidenceAtMs) {
|
||||
@@ -705,13 +915,12 @@ export async function completeLocalReconciliation(
|
||||
uid,
|
||||
);
|
||||
if (head.state !== 'reconciliation_completed') {
|
||||
assertSourceHead(head, receipt.sourceHeadDigest, automation);
|
||||
if (automation !== null) {
|
||||
validateLocalReconciliationAutomationAppliedStorage(
|
||||
automation.paths,
|
||||
automation.intent,
|
||||
uid,
|
||||
);
|
||||
assertSourceHead(head, receipt.sourceHeadDigest, automation, secretConfig);
|
||||
if (automation?.storageState === 'completed') {
|
||||
fail('automation rollback backup was collected before completion');
|
||||
}
|
||||
if (secretConfig?.storageState === 'completed') {
|
||||
fail('secret config rollback backup was collected before completion');
|
||||
}
|
||||
head = advanceCompletedHead(terminal, receipt, uid);
|
||||
} else if (head.sourceRecordDigest !== receipt.completionDigest) {
|
||||
@@ -726,6 +935,14 @@ export async function completeLocalReconciliation(
|
||||
);
|
||||
dependencies.afterBackupCollected?.();
|
||||
}
|
||||
if (secretConfig !== null) {
|
||||
collectLocalReconciliationSecretConfigCompletedStorage(
|
||||
secretConfig.paths,
|
||||
secretConfig.intent,
|
||||
uid,
|
||||
);
|
||||
dependencies.afterSecretConfigBackupCollected?.();
|
||||
}
|
||||
return result(command.operation, status, receipt, head);
|
||||
}
|
||||
|
||||
@@ -777,6 +994,7 @@ export async function verifyLocalReconciliationCompletion(
|
||||
expectedApplicationPlanDigest: receipt.applicationPlanDigest,
|
||||
expectedHeadDigest: receipt.sourceHeadDigest,
|
||||
automation: command.request.automation,
|
||||
secretConfig: command.request.secretConfig,
|
||||
runHistory: command.request.runHistory,
|
||||
completedAtMs: receipt.completedAtMs,
|
||||
}),
|
||||
@@ -787,13 +1005,25 @@ export async function verifyLocalReconciliationCompletion(
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const secretConfig = await secretConfigProof(
|
||||
syntheticCompleteCommand,
|
||||
terminal,
|
||||
automation,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const runHistory = await runHistoryProof(
|
||||
syntheticCompleteCommand,
|
||||
terminal,
|
||||
uid,
|
||||
dependencies,
|
||||
);
|
||||
const domains = domainEvidence(terminal, automation, runHistory);
|
||||
const domains = domainEvidence(
|
||||
terminal,
|
||||
automation,
|
||||
secretConfig,
|
||||
runHistory,
|
||||
);
|
||||
validateReceiptBinding(
|
||||
receipt,
|
||||
terminal,
|
||||
@@ -818,6 +1048,13 @@ export async function verifyLocalReconciliationCompletion(
|
||||
uid,
|
||||
);
|
||||
}
|
||||
if (secretConfig !== null) {
|
||||
validateLocalReconciliationSecretConfigCompletedStorage(
|
||||
secretConfig.paths,
|
||||
secretConfig.intent,
|
||||
uid,
|
||||
);
|
||||
}
|
||||
return result(command.operation, 'verified', receipt, head);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,14 @@ export interface LocalReconciliationCompletionDomainEvidence {
|
||||
readonly evidenceKind:
|
||||
| 'application_summary'
|
||||
| 'automation_apply'
|
||||
| 'run_history_preservation';
|
||||
| 'run_history_preservation'
|
||||
| 'secret_config_application';
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationCompletionReceipt {
|
||||
readonly schema: typeof RECEIPT_SCHEMA;
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly schemaVersion: 1 | 2 | 3;
|
||||
readonly state: 'reconciliation_completed';
|
||||
readonly completionId: string;
|
||||
readonly applicationId: string;
|
||||
@@ -34,7 +35,7 @@ export interface LocalReconciliationCompletionReceipt {
|
||||
readonly applicationPlanDigest: string;
|
||||
readonly sourceHeadDigest: string;
|
||||
readonly domains: readonly Readonly<LocalReconciliationCompletionDomainEvidence>[];
|
||||
readonly adapterCount: 0 | 1 | 2;
|
||||
readonly adapterCount: 0 | 1 | 2 | 3;
|
||||
readonly completedAtMs: number;
|
||||
readonly completionDigest: string;
|
||||
}
|
||||
@@ -68,7 +69,7 @@ function exact(
|
||||
function domainEvidence(
|
||||
value: unknown,
|
||||
expectedDomain: LocalReconciliationPlanDomain,
|
||||
schemaVersion: 1 | 2,
|
||||
schemaVersion: 1 | 2 | 3,
|
||||
): Readonly<LocalReconciliationCompletionDomainEvidence> {
|
||||
const selected = exact(
|
||||
value,
|
||||
@@ -83,13 +84,18 @@ function domainEvidence(
|
||||
selected.action === 'adapter_required' &&
|
||||
selected.evidenceKind === 'automation_apply';
|
||||
const runHistory =
|
||||
schemaVersion === 2 &&
|
||||
schemaVersion >= 2 &&
|
||||
expectedDomain === 'run_history' &&
|
||||
selected.action === 'adapter_required' &&
|
||||
selected.evidenceKind === 'run_history_preservation';
|
||||
const secretConfig =
|
||||
schemaVersion === 3 &&
|
||||
expectedDomain === 'secret_and_config' &&
|
||||
selected.action === 'adapter_required' &&
|
||||
selected.evidenceKind === 'secret_config_application';
|
||||
if (
|
||||
selected.domain !== expectedDomain ||
|
||||
(!noEffect && !automation && !runHistory) ||
|
||||
(!noEffect && !automation && !runHistory && !secretConfig) ||
|
||||
typeof selected.evidenceDigest !== 'string' ||
|
||||
!DIGEST.test(selected.evidenceDigest)
|
||||
) {
|
||||
@@ -110,8 +116,12 @@ export function buildLocalReconciliationCompletionReceipt(
|
||||
>,
|
||||
): Readonly<LocalReconciliationCompletionReceipt> {
|
||||
const schemaVersion = input.domains.some(
|
||||
(domain) => domain.evidenceKind === 'run_history_preservation',
|
||||
(domain) => domain.evidenceKind === 'secret_config_application',
|
||||
)
|
||||
? (3 as const)
|
||||
: input.domains.some(
|
||||
(domain) => domain.evidenceKind === 'run_history_preservation',
|
||||
)
|
||||
? (2 as const)
|
||||
: (1 as const);
|
||||
const payload = Object.freeze({
|
||||
@@ -154,7 +164,11 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
||||
if (!Array.isArray(selected.domains) || selected.domains.length !== 8) {
|
||||
fail('receipt domain catalog is invalid');
|
||||
}
|
||||
if (selected.schemaVersion !== 1 && selected.schemaVersion !== 2) {
|
||||
if (
|
||||
selected.schemaVersion !== 1 &&
|
||||
selected.schemaVersion !== 2 &&
|
||||
selected.schemaVersion !== 3
|
||||
) {
|
||||
fail('receipt schema version is invalid');
|
||||
}
|
||||
const schemaVersion = selected.schemaVersion;
|
||||
@@ -171,10 +185,23 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
||||
const normalized = Object.freeze({ ...raw, domains });
|
||||
if (
|
||||
selected.schema !== RECEIPT_SCHEMA ||
|
||||
(schemaVersion === 2) !==
|
||||
domains.some(
|
||||
(schemaVersion === 1 &&
|
||||
domains.some((domain) =>
|
||||
['run_history_preservation', 'secret_config_application'].includes(
|
||||
domain.evidenceKind,
|
||||
),
|
||||
)) ||
|
||||
(schemaVersion === 2 &&
|
||||
(!domains.some(
|
||||
(domain) => domain.evidenceKind === 'run_history_preservation',
|
||||
) ||
|
||||
domains.some(
|
||||
(domain) => domain.evidenceKind === 'secret_config_application',
|
||||
))) ||
|
||||
(schemaVersion === 3 &&
|
||||
!domains.some(
|
||||
(domain) => domain.evidenceKind === 'secret_config_application',
|
||||
)) ||
|
||||
selected.state !== 'reconciliation_completed' ||
|
||||
typeof selected.completionId !== 'string' ||
|
||||
!UUID_V4.test(selected.completionId) ||
|
||||
@@ -195,7 +222,7 @@ export function normalizeLocalReconciliationCompletionReceipt(
|
||||
].every(
|
||||
(candidate) => typeof candidate === 'string' && DIGEST.test(candidate),
|
||||
) ||
|
||||
![0, 1, 2].includes(selected.adapterCount as number) ||
|
||||
![0, 1, 2, 3].includes(selected.adapterCount as number) ||
|
||||
selected.adapterCount !== adapterCount ||
|
||||
!Number.isSafeInteger(selected.completedAtMs) ||
|
||||
(selected.completedAtMs as number) < 0 ||
|
||||
|
||||
@@ -54,6 +54,9 @@ const {
|
||||
const {
|
||||
createLocalDataDirectoryApplicationCommit,
|
||||
} = require('@qinglong/local-sqlite/data-directory-application-commit');
|
||||
const {
|
||||
applyPreparedReconciliationSecretConfigApplication,
|
||||
} = require('@qinglong/local-admin/reconciliation-secret-and-config-application');
|
||||
const {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
assertLocalCutoverTargetHead,
|
||||
@@ -71,6 +74,16 @@ const {
|
||||
const {
|
||||
targetStoppedEvidence,
|
||||
} = require('../dist/deployment/cutover/targetStopRecordEvidence.js');
|
||||
const {
|
||||
collectLocalReconciliationSecretConfigCompletedStorage,
|
||||
localReconciliationSecretConfigApplyPaths,
|
||||
readLocalReconciliationSecretConfigApplyIntent,
|
||||
validateLocalReconciliationSecretConfigCompletedStorage,
|
||||
} = require('../dist/deployment/reconciliation/application/secret-and-config/application/storage.js');
|
||||
const {
|
||||
buildLocalReconciliationCompletionReceipt,
|
||||
normalizeLocalReconciliationCompletionReceipt,
|
||||
} = require('../dist/deployment/reconciliation/completion/evidence.js');
|
||||
|
||||
function digest(value) {
|
||||
return crypto
|
||||
@@ -801,9 +814,27 @@ function automationReadyDatabaseInitializer() {
|
||||
};
|
||||
}
|
||||
|
||||
function insertSecretConfigOwnerBinding(targetDatabasePath) {
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
target.exec(`
|
||||
INSERT OR IGNORE INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'review-owner', 1, 'active', 'owner',
|
||||
'secret-config-apply-owner-binding', 'user', 'review-owner', 1
|
||||
);
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
PRAGMA journal_mode=DELETE;
|
||||
`);
|
||||
target.close();
|
||||
}
|
||||
|
||||
function secretConfigDatabaseInitializer({
|
||||
active = false,
|
||||
configs = false,
|
||||
ownerBinding = true,
|
||||
} = {}) {
|
||||
return ({ legacySourcePath, recoveryPath, targetDatabasePath }) => {
|
||||
const legacy = new DatabaseSync(legacySourcePath);
|
||||
@@ -850,20 +881,16 @@ function secretConfigDatabaseInitializer({
|
||||
);
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
fs.chmodSync(targetDatabasePath, 0o600);
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
target.exec(`
|
||||
INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'review-owner', 1, 'active', 'owner',
|
||||
'secret-config-apply-owner-binding', 'user', 'review-owner', 1
|
||||
);
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
PRAGMA journal_mode=DELETE;
|
||||
`);
|
||||
target.close();
|
||||
if (ownerBinding) {
|
||||
insertSecretConfigOwnerBinding(targetDatabasePath);
|
||||
} else {
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
target.exec(`
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
PRAGMA journal_mode=DELETE;
|
||||
`);
|
||||
target.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1255,6 +1282,7 @@ async function secretConfigPlanFixture(t, options = {}) {
|
||||
initializeDatabases: secretConfigDatabaseInitializer({
|
||||
active: options.active === true,
|
||||
configs: options.configs === true,
|
||||
ownerBinding: options.ownerBinding !== false,
|
||||
}),
|
||||
mutateTarget({ targetDatabasePath }) {
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
@@ -1552,6 +1580,7 @@ async function plannedSecretConfigDecisionFixture(t, options = {}) {
|
||||
reviewId: options.reviewId,
|
||||
applicationId: options.applicationId,
|
||||
secretConfigId: options.secretConfigId,
|
||||
ownerBinding: options.ownerBinding,
|
||||
});
|
||||
const planned = await planLocalReconciliationSecretConfig(
|
||||
state.secretConfigCommand,
|
||||
@@ -1823,6 +1852,124 @@ async function appliedAutomationFixture(t, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function appliedSecretConfigFixture(t, options = {}) {
|
||||
const suffix = options.suffix ?? 'completion-secret-config';
|
||||
const state = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix,
|
||||
planId: options.planId ?? '00000000-0000-4000-8000-000000000521',
|
||||
reviewId: options.reviewId ?? '00000000-0000-4000-8000-000000000522',
|
||||
applicationId:
|
||||
options.applicationId ?? '00000000-0000-4000-8000-000000000523',
|
||||
secretConfigId:
|
||||
options.secretConfigId ?? '00000000-0000-4000-8000-000000000524',
|
||||
ownerBinding: false,
|
||||
});
|
||||
const decisionId =
|
||||
options.decisionId ?? '019b0000-0000-7000-8000-000000000521';
|
||||
const prepareCommand = secretConfigDecisionPrepareCommand(state, decisionId);
|
||||
const prepared = await prepareLocalReconciliationSecretConfigDecision(
|
||||
prepareCommand,
|
||||
);
|
||||
const review = secretConfigDecisionFile(
|
||||
state,
|
||||
{ result: prepared },
|
||||
[
|
||||
{
|
||||
disposition: 'preserve_disabled',
|
||||
reason: 'reviewed_disabled_preservation',
|
||||
},
|
||||
],
|
||||
suffix,
|
||||
);
|
||||
const decisionCommit = secretConfigDecisionCommitFixture(
|
||||
state,
|
||||
{ result: prepared, commandOptions: prepareCommand.options },
|
||||
review.filePath,
|
||||
);
|
||||
const decision = await commitLocalReconciliationSecretConfigDecision(
|
||||
decisionCommit.command,
|
||||
decisionCommit.dependencies,
|
||||
);
|
||||
assert.equal(decision.outcome, 'ready');
|
||||
|
||||
const secretKeyringPath = path.join(
|
||||
state.deploymentRoot,
|
||||
`local-secret-keyring-${suffix}.json`,
|
||||
);
|
||||
await provisionLocalSecretKeyring(secretKeyringPath);
|
||||
const secretConfigApplyRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
`secret-config-apply-${suffix}`,
|
||||
);
|
||||
fs.mkdirSync(secretConfigApplyRoot, { mode: 0o700 });
|
||||
const applyOptions = {
|
||||
...prepareCommand.options,
|
||||
secretConfigApplyRoot,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
secretKeyringPath,
|
||||
ownerPepperKeyringDirectory:
|
||||
state.command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: state.command.options.credentialFilePath,
|
||||
};
|
||||
const appliedAtMs = decisionCommit.command.request.committedAtMs + 1;
|
||||
const applyCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.apply',
|
||||
options: applyOptions,
|
||||
request: {
|
||||
decisionId,
|
||||
secretConfigId: state.secretConfigId,
|
||||
expectedDecisionDigest: decision.decisionDigest,
|
||||
expectedHeadDigest: decision.instanceHeadDigest,
|
||||
mutationId: options.mutationId ?? '00000000-0000-4000-8000-000000000525',
|
||||
requestId: `secret-config-apply-${suffix}`,
|
||||
appliedAtMs,
|
||||
},
|
||||
};
|
||||
const applyDependencies = {
|
||||
async openAuthenticationDatabase() {
|
||||
return { async close() {} };
|
||||
},
|
||||
async authenticate(_database, authenticationOptions) {
|
||||
const authenticatedAtMs = authenticationOptions.now();
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'review-owner' },
|
||||
authenticationId: 'local_reconciliation_secret_config_apply:test',
|
||||
authenticatedAtMs,
|
||||
expiresAtMs: authenticatedAtMs + 60 * 60 * 1_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
databaseFence: {
|
||||
credentialId: 'review-owner',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'review-owner-v1',
|
||||
pepperVersion: 1,
|
||||
},
|
||||
async confirm() {},
|
||||
};
|
||||
},
|
||||
async applyApplication(input) {
|
||||
insertSecretConfigOwnerBinding(state.targetDatabasePath);
|
||||
return applyPreparedReconciliationSecretConfigApplication(input);
|
||||
},
|
||||
};
|
||||
const applied = await applyLocalReconciliationSecretConfig(
|
||||
applyCommand,
|
||||
applyDependencies,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
decisionId,
|
||||
decision,
|
||||
secretConfigApplyRoot,
|
||||
applyOptions,
|
||||
applyCommand,
|
||||
applyDependencies,
|
||||
applied,
|
||||
};
|
||||
}
|
||||
|
||||
function dockerReadSealedSqlite(assetsDirectory, mode) {
|
||||
const source =
|
||||
mode === 'main_only_immutable'
|
||||
@@ -4205,6 +4352,312 @@ test('Secret/Config apply publishes encrypted material atomically and recovers e
|
||||
assert.equal(databaseCloses, authentications);
|
||||
});
|
||||
|
||||
test('completion v3 proves Secret/Config apply but preserves rollback authority while other domains remain manual', async (t) => {
|
||||
const state = await appliedSecretConfigFixture(t, {
|
||||
suffix: 'completion-v3',
|
||||
});
|
||||
const completionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'completion-secret-config-v3',
|
||||
);
|
||||
fs.mkdirSync(completionRoot, { mode: 0o700 });
|
||||
const secretConfig = {
|
||||
secretConfigId: state.secretConfigId,
|
||||
decisionId: state.decisionId,
|
||||
expectedApplyDigest: state.applied.applyDigest,
|
||||
};
|
||||
const command = {
|
||||
schemaVersion: 3,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
completionRoot,
|
||||
automation: null,
|
||||
secretConfig: {
|
||||
secretConfigRoot: state.secretConfigRoot,
|
||||
secretConfigDecisionRoot: state.secretConfigDecisionRoot,
|
||||
secretConfigApplyRoot: state.secretConfigApplyRoot,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
},
|
||||
runHistory: null,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
completionId: '00000000-0000-4000-8000-000000000526',
|
||||
applicationId: state.application.applicationId,
|
||||
expectedApplicationPlanDigest: state.application.applicationPlanDigest,
|
||||
expectedHeadDigest: state.applied.instanceHeadDigest,
|
||||
automation: null,
|
||||
secretConfig,
|
||||
runHistory: null,
|
||||
completedAtMs: state.applyCommand.request.appliedAtMs + 1,
|
||||
},
|
||||
};
|
||||
const applyRoot = path.join(
|
||||
state.secretConfigApplyRoot,
|
||||
state.secretConfigId,
|
||||
);
|
||||
const backupRoot = path.join(applyRoot, 'backup');
|
||||
const backupPath = path.join(backupRoot, 'before.sqlite');
|
||||
const materialPath = path.join(applyRoot, 'materials.ndjson');
|
||||
assert.equal(fs.existsSync(backupPath), true);
|
||||
|
||||
await assert.rejects(
|
||||
completeLocalReconciliation(command),
|
||||
/identity_policy_audit is not terminally reconciled/,
|
||||
);
|
||||
assert.equal(fs.existsSync(backupPath), true);
|
||||
assert.equal(
|
||||
readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.captureCommand.request.instanceId,
|
||||
state.uid,
|
||||
).state,
|
||||
'reconciliation_secret_config_applied',
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(completionRoot, command.request.completionId)),
|
||||
false,
|
||||
);
|
||||
|
||||
const domainNames = [
|
||||
'schema_lineage',
|
||||
'automation',
|
||||
'secret_and_config',
|
||||
'run_history',
|
||||
'plugin_package',
|
||||
'ai_and_tool',
|
||||
'identity_policy_audit',
|
||||
'unknown',
|
||||
];
|
||||
const domains = domainNames.map((domain) =>
|
||||
domain === 'secret_and_config'
|
||||
? {
|
||||
domain,
|
||||
action: 'adapter_required',
|
||||
evidenceKind: 'secret_config_application',
|
||||
evidenceDigest: state.applied.applyDigest,
|
||||
}
|
||||
: {
|
||||
domain,
|
||||
action: 'no_effect',
|
||||
evidenceKind: 'application_summary',
|
||||
evidenceDigest: 'a'.repeat(64),
|
||||
},
|
||||
);
|
||||
const receipt = buildLocalReconciliationCompletionReceipt({
|
||||
completionId: command.request.completionId,
|
||||
applicationId: command.request.applicationId,
|
||||
profile: state.captureCommand.request.profile,
|
||||
instanceId: state.captureCommand.request.instanceId,
|
||||
cutoverId: state.captureCommand.request.cutoverId,
|
||||
generation: 1,
|
||||
activationDigest: state.captureCommand.request.expectedActivationDigest,
|
||||
applicationPlanDigest: state.application.applicationPlanDigest,
|
||||
sourceHeadDigest: state.applied.instanceHeadDigest,
|
||||
domains,
|
||||
adapterCount: 1,
|
||||
completedAtMs: command.request.completedAtMs,
|
||||
});
|
||||
assert.equal(receipt.schemaVersion, 3);
|
||||
assert.deepEqual(
|
||||
normalizeLocalReconciliationCompletionReceipt(receipt),
|
||||
receipt,
|
||||
);
|
||||
|
||||
const currentHead = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.captureCommand.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
const durableHead = advanceLocalCutoverInstanceHead(
|
||||
{
|
||||
options: { deploymentRoot: state.deploymentRoot },
|
||||
request: {
|
||||
cutoverId: state.captureCommand.request.cutoverId,
|
||||
profile: state.captureCommand.request.profile,
|
||||
instanceId: state.captureCommand.request.instanceId,
|
||||
expectedActivationDigest:
|
||||
state.captureCommand.request.expectedActivationDigest,
|
||||
requestedAtMs: command.request.completedAtMs,
|
||||
},
|
||||
},
|
||||
state.uid,
|
||||
'reconciliation_completed',
|
||||
currentHead.generation,
|
||||
receipt.completionDigest,
|
||||
);
|
||||
assert.equal(durableHead.state, 'reconciliation_completed');
|
||||
const selected = localReconciliationSecretConfigApplyPaths(
|
||||
state.secretConfigApplyRoot,
|
||||
state.secretConfigId,
|
||||
);
|
||||
const intent = readLocalReconciliationSecretConfigApplyIntent(
|
||||
selected,
|
||||
state.uid,
|
||||
);
|
||||
collectLocalReconciliationSecretConfigCompletedStorage(
|
||||
selected,
|
||||
intent,
|
||||
state.uid,
|
||||
);
|
||||
collectLocalReconciliationSecretConfigCompletedStorage(
|
||||
selected,
|
||||
intent,
|
||||
state.uid,
|
||||
);
|
||||
validateLocalReconciliationSecretConfigCompletedStorage(
|
||||
selected,
|
||||
intent,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(fs.existsSync(backupPath), false);
|
||||
assert.deepEqual(fs.readdirSync(backupRoot), []);
|
||||
assert.deepEqual(fs.readdirSync(path.join(applyRoot, 'rollback-work')), []);
|
||||
assert.equal(fs.statSync(backupRoot).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(materialPath).mode & 0o777, 0o400);
|
||||
assert.ok(fs.statSync(materialPath).size < 64 * 1024);
|
||||
assert.deepEqual(
|
||||
receipt.domains.find((domain) => domain.domain === 'secret_and_config'),
|
||||
{
|
||||
domain: 'secret_and_config',
|
||||
action: 'adapter_required',
|
||||
evidenceKind: 'secret_config_application',
|
||||
evidenceDigest: state.applied.applyDigest,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('completion v3 rejects rolled-back Secret/Config evidence', async (t) => {
|
||||
const state = await appliedSecretConfigFixture(t, {
|
||||
suffix: 'completion-rolled-back',
|
||||
planId: '00000000-0000-4000-8000-000000000531',
|
||||
reviewId: '00000000-0000-4000-8000-000000000532',
|
||||
applicationId: '00000000-0000-4000-8000-000000000533',
|
||||
secretConfigId: '00000000-0000-4000-8000-000000000534',
|
||||
decisionId: '019b0000-0000-7000-8000-000000000531',
|
||||
mutationId: '00000000-0000-4000-8000-000000000535',
|
||||
});
|
||||
const rollback = await rollbackLocalReconciliationSecretConfigApply(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.apply.rollback',
|
||||
options: state.applyOptions,
|
||||
request: {
|
||||
decisionId: state.decisionId,
|
||||
secretConfigId: state.secretConfigId,
|
||||
expectedApplyDigest: state.applied.applyDigest,
|
||||
expectedHeadDigest: state.applied.instanceHeadDigest,
|
||||
rolledBackAtMs: state.applyCommand.request.appliedAtMs + 1,
|
||||
},
|
||||
},
|
||||
state.applyDependencies,
|
||||
);
|
||||
const completionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'completion-secret-config-rolled-back',
|
||||
);
|
||||
fs.mkdirSync(completionRoot, { mode: 0o700 });
|
||||
await assert.rejects(
|
||||
completeLocalReconciliation({
|
||||
schemaVersion: 3,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
completionRoot,
|
||||
automation: null,
|
||||
secretConfig: {
|
||||
secretConfigRoot: state.secretConfigRoot,
|
||||
secretConfigDecisionRoot: state.secretConfigDecisionRoot,
|
||||
secretConfigApplyRoot: state.secretConfigApplyRoot,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
},
|
||||
runHistory: null,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
completionId: '00000000-0000-4000-8000-000000000536',
|
||||
applicationId: state.application.applicationId,
|
||||
expectedApplicationPlanDigest: state.application.applicationPlanDigest,
|
||||
expectedHeadDigest: rollback.instanceHeadDigest,
|
||||
automation: null,
|
||||
secretConfig: {
|
||||
secretConfigId: state.secretConfigId,
|
||||
decisionId: state.decisionId,
|
||||
expectedApplyDigest: state.applied.applyDigest,
|
||||
},
|
||||
runHistory: null,
|
||||
completedAtMs: state.applyCommand.request.appliedAtMs + 2,
|
||||
},
|
||||
}),
|
||||
/secret config apply evidence is detached/,
|
||||
);
|
||||
});
|
||||
|
||||
test('completion v3 rejects Secret/Config target drift without collecting rollback authority', async (t) => {
|
||||
const state = await appliedSecretConfigFixture(t, {
|
||||
suffix: 'completion-target-drift',
|
||||
planId: '00000000-0000-4000-8000-000000000541',
|
||||
reviewId: '00000000-0000-4000-8000-000000000542',
|
||||
applicationId: '00000000-0000-4000-8000-000000000543',
|
||||
secretConfigId: '00000000-0000-4000-8000-000000000544',
|
||||
decisionId: '019b0000-0000-7000-8000-000000000541',
|
||||
mutationId: '00000000-0000-4000-8000-000000000545',
|
||||
});
|
||||
const target = new DatabaseSync(state.targetDatabasePath);
|
||||
target.exec('PRAGMA user_version=77');
|
||||
target.close();
|
||||
const completionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'completion-secret-config-target-drift',
|
||||
);
|
||||
fs.mkdirSync(completionRoot, { mode: 0o700 });
|
||||
const backupPath = path.join(
|
||||
state.secretConfigApplyRoot,
|
||||
state.secretConfigId,
|
||||
'backup',
|
||||
'before.sqlite',
|
||||
);
|
||||
await assert.rejects(
|
||||
completeLocalReconciliation({
|
||||
schemaVersion: 3,
|
||||
operation: 'local.deployment.reconciliation.complete',
|
||||
options: {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
applicationRoot: state.applicationRoot,
|
||||
completionRoot,
|
||||
automation: null,
|
||||
secretConfig: {
|
||||
secretConfigRoot: state.secretConfigRoot,
|
||||
secretConfigDecisionRoot: state.secretConfigDecisionRoot,
|
||||
secretConfigApplyRoot: state.secretConfigApplyRoot,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
},
|
||||
runHistory: null,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
completionId: '00000000-0000-4000-8000-000000000546',
|
||||
applicationId: state.application.applicationId,
|
||||
expectedApplicationPlanDigest: state.application.applicationPlanDigest,
|
||||
expectedHeadDigest: state.applied.instanceHeadDigest,
|
||||
automation: null,
|
||||
secretConfig: {
|
||||
secretConfigId: state.secretConfigId,
|
||||
decisionId: state.decisionId,
|
||||
expectedApplyDigest: state.applied.applyDigest,
|
||||
},
|
||||
runHistory: null,
|
||||
completedAtMs: state.applyCommand.request.appliedAtMs + 1,
|
||||
},
|
||||
}),
|
||||
/secret config target drifted after apply/,
|
||||
);
|
||||
assert.equal(fs.existsSync(backupPath), true);
|
||||
});
|
||||
|
||||
test('Secret/Config decision rejects manual plans, invalid candidate choices and reviewer drift', async (t) => {
|
||||
const manual = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix: 'decision-manual-plan',
|
||||
|
||||
Reference in New Issue
Block a user