mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-24 04:58:37 +08:00
fix(ql3): bind secret planning to automation target
This commit is contained in:
+77
-8
@@ -16,10 +16,24 @@ export interface LocalReconciliationSecretConfigOptions {
|
|||||||
readonly allowRootService: boolean;
|
readonly allowRootService: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigAutomationOptions
|
||||||
|
extends LocalReconciliationSecretConfigOptions {
|
||||||
|
readonly automationApplyRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalReconciliationSecretConfigAutomationBinding {
|
||||||
|
readonly automationId: string;
|
||||||
|
readonly decisionId: string;
|
||||||
|
readonly expectedApplyDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalReconciliationSecretConfigPlanCommand {
|
export interface LocalReconciliationSecretConfigPlanCommand {
|
||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1 | 2;
|
||||||
readonly operation: 'local.deployment.reconciliation.secret-config.plan';
|
readonly operation: 'local.deployment.reconciliation.secret-config.plan';
|
||||||
readonly options: Readonly<LocalReconciliationSecretConfigOptions>;
|
readonly options: Readonly<
|
||||||
|
| LocalReconciliationSecretConfigOptions
|
||||||
|
| LocalReconciliationSecretConfigAutomationOptions
|
||||||
|
>;
|
||||||
readonly request: Readonly<{
|
readonly request: Readonly<{
|
||||||
secretConfigId: string;
|
secretConfigId: string;
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
@@ -28,6 +42,7 @@ export interface LocalReconciliationSecretConfigPlanCommand {
|
|||||||
decisionFilePath: string;
|
decisionFilePath: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
preparedAtMs: number;
|
preparedAtMs: number;
|
||||||
|
automation?: Readonly<LocalReconciliationSecretConfigAutomationBinding>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,13 +161,18 @@ function projectId(value: unknown): string {
|
|||||||
|
|
||||||
function normalizeOptions(
|
function normalizeOptions(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): Readonly<LocalReconciliationSecretConfigOptions> {
|
schemaVersion: 1 | 2,
|
||||||
|
): Readonly<
|
||||||
|
| LocalReconciliationSecretConfigOptions
|
||||||
|
| LocalReconciliationSecretConfigAutomationOptions
|
||||||
|
> {
|
||||||
const options = object(value, 'options');
|
const options = object(value, 'options');
|
||||||
exact(
|
exact(
|
||||||
options,
|
options,
|
||||||
[
|
[
|
||||||
'allowRootService',
|
'allowRootService',
|
||||||
'applicationRoot',
|
'applicationRoot',
|
||||||
|
...(schemaVersion === 2 ? ['automationApplyRoot'] : []),
|
||||||
'deploymentRoot',
|
'deploymentRoot',
|
||||||
'secretConfigRoot',
|
'secretConfigRoot',
|
||||||
],
|
],
|
||||||
@@ -169,6 +189,9 @@ function normalizeOptions(
|
|||||||
safePath(options.deploymentRoot, 'deploymentRoot'),
|
safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||||
safePath(options.applicationRoot, 'applicationRoot'),
|
safePath(options.applicationRoot, 'applicationRoot'),
|
||||||
safePath(options.secretConfigRoot, 'secretConfigRoot'),
|
safePath(options.secretConfigRoot, 'secretConfigRoot'),
|
||||||
|
...(schemaVersion === 2
|
||||||
|
? [safePath(options.automationApplyRoot, 'automationApplyRoot')]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
for (let left = 0; left < roots.length; left += 1) {
|
for (let left = 0; left < roots.length; left += 1) {
|
||||||
for (let right = left + 1; right < roots.length; right += 1) {
|
for (let right = left + 1; right < roots.length; right += 1) {
|
||||||
@@ -184,6 +207,7 @@ function normalizeOptions(
|
|||||||
deploymentRoot: roots[0]!,
|
deploymentRoot: roots[0]!,
|
||||||
applicationRoot: roots[1]!,
|
applicationRoot: roots[1]!,
|
||||||
secretConfigRoot: roots[2]!,
|
secretConfigRoot: roots[2]!,
|
||||||
|
...(schemaVersion === 2 ? { automationApplyRoot: roots[3]! } : {}),
|
||||||
allowRootService: options.allowRootService,
|
allowRootService: options.allowRootService,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -194,7 +218,11 @@ function command(
|
|||||||
| LocalReconciliationSecretConfigPlanCommand['operation']
|
| LocalReconciliationSecretConfigPlanCommand['operation']
|
||||||
| LocalReconciliationSecretConfigVerifyCommand['operation'],
|
| LocalReconciliationSecretConfigVerifyCommand['operation'],
|
||||||
): Readonly<{
|
): Readonly<{
|
||||||
options: Readonly<LocalReconciliationSecretConfigOptions>;
|
schemaVersion: 1 | 2;
|
||||||
|
options: Readonly<
|
||||||
|
| LocalReconciliationSecretConfigOptions
|
||||||
|
| LocalReconciliationSecretConfigAutomationOptions
|
||||||
|
>;
|
||||||
request: Record<string, unknown>;
|
request: Record<string, unknown>;
|
||||||
}> {
|
}> {
|
||||||
const selected = object(value, 'command');
|
const selected = object(value, 'command');
|
||||||
@@ -203,15 +231,47 @@ function command(
|
|||||||
['operation', 'options', 'request', 'schemaVersion'],
|
['operation', 'options', 'request', 'schemaVersion'],
|
||||||
'command',
|
'command',
|
||||||
);
|
);
|
||||||
if (selected.schemaVersion !== 1 || selected.operation !== operation) {
|
const schemaVersion = selected.schemaVersion;
|
||||||
|
if (
|
||||||
|
(schemaVersion !== 1 &&
|
||||||
|
(schemaVersion !== 2 ||
|
||||||
|
operation !== 'local.deployment.reconciliation.secret-config.plan')) ||
|
||||||
|
selected.operation !== operation
|
||||||
|
) {
|
||||||
configurationError('command version or operation is invalid');
|
configurationError('command version or operation is invalid');
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
options: normalizeOptions(selected.options),
|
schemaVersion,
|
||||||
|
options: normalizeOptions(selected.options, schemaVersion),
|
||||||
request: object(selected.request, 'request'),
|
request: object(selected.request, 'request'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function automationBinding(
|
||||||
|
value: unknown,
|
||||||
|
): Readonly<LocalReconciliationSecretConfigAutomationBinding> {
|
||||||
|
const selected = object(value, 'automation binding');
|
||||||
|
exact(
|
||||||
|
selected,
|
||||||
|
['automationId', 'decisionId', 'expectedApplyDigest'],
|
||||||
|
'automation binding',
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
automationId: identifier(selected.automationId, 'automationId'),
|
||||||
|
decisionId:
|
||||||
|
typeof selected.decisionId === 'string' &&
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||||
|
selected.decisionId,
|
||||||
|
)
|
||||||
|
? selected.decisionId
|
||||||
|
: configurationError('decisionId is invalid'),
|
||||||
|
expectedApplyDigest: digest(
|
||||||
|
selected.expectedApplyDigest,
|
||||||
|
'expectedApplyDigest',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): Readonly<LocalReconciliationSecretConfigPlanCommand> {
|
): Readonly<LocalReconciliationSecretConfigPlanCommand> {
|
||||||
@@ -223,6 +283,7 @@ export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
|||||||
selected.request,
|
selected.request,
|
||||||
[
|
[
|
||||||
'applicationId',
|
'applicationId',
|
||||||
|
...(selected.schemaVersion === 2 ? ['automation'] : []),
|
||||||
'decisionFilePath',
|
'decisionFilePath',
|
||||||
'expectedApplicationPlanDigest',
|
'expectedApplicationPlanDigest',
|
||||||
'expectedHeadDigest',
|
'expectedHeadDigest',
|
||||||
@@ -255,7 +316,7 @@ export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
|||||||
configurationError('preparedAtMs is invalid');
|
configurationError('preparedAtMs is invalid');
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1,
|
schemaVersion: selected.schemaVersion,
|
||||||
operation: 'local.deployment.reconciliation.secret-config.plan',
|
operation: 'local.deployment.reconciliation.secret-config.plan',
|
||||||
options: selected.options,
|
options: selected.options,
|
||||||
request: Object.freeze({
|
request: Object.freeze({
|
||||||
@@ -278,6 +339,9 @@ export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
|||||||
decisionFilePath,
|
decisionFilePath,
|
||||||
projectId: projectId(selected.request.projectId),
|
projectId: projectId(selected.request.projectId),
|
||||||
preparedAtMs: selected.request.preparedAtMs as number,
|
preparedAtMs: selected.request.preparedAtMs as number,
|
||||||
|
...(selected.schemaVersion === 2
|
||||||
|
? { automation: automationBinding(selected.request.automation) }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -297,7 +361,12 @@ export function normalizeLocalReconciliationSecretConfigVerifyCommand(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
operation: 'local.deployment.reconciliation.secret-config.verify',
|
operation: 'local.deployment.reconciliation.secret-config.verify',
|
||||||
options: selected.options,
|
options: Object.freeze({
|
||||||
|
deploymentRoot: selected.options.deploymentRoot,
|
||||||
|
applicationRoot: selected.options.applicationRoot,
|
||||||
|
secretConfigRoot: selected.options.secretConfigRoot,
|
||||||
|
allowRootService: selected.options.allowRootService,
|
||||||
|
}),
|
||||||
request: Object.freeze({
|
request: Object.freeze({
|
||||||
secretConfigId: identifier(
|
secretConfigId: identifier(
|
||||||
selected.request.secretConfigId,
|
selected.request.secretConfigId,
|
||||||
|
|||||||
+251
-23
@@ -1,7 +1,9 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
|
import { inspectLocalSqliteSnapshot } from '@qinglong/local-sqlite/rollout-safety';
|
||||||
|
|
||||||
import { currentIdentity } from '../../../foundation/contract';
|
import { currentIdentity } from '../../../foundation/contract';
|
||||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||||
@@ -27,6 +29,12 @@ import {
|
|||||||
readLocalReconciliationApplicationTerminal,
|
readLocalReconciliationApplicationTerminal,
|
||||||
type LocalReconciliationApplicationTerminal,
|
type LocalReconciliationApplicationTerminal,
|
||||||
} from '../coordinator';
|
} from '../coordinator';
|
||||||
|
import { verifyLocalReconciliationAutomationApply } from '../automation/applyCoordinator';
|
||||||
|
import {
|
||||||
|
localReconciliationAutomationApplyPaths,
|
||||||
|
readLocalReconciliationAutomationApplyIntent,
|
||||||
|
readLocalReconciliationAutomationApplyReceipt,
|
||||||
|
} from '../automation/applyStorage';
|
||||||
import {
|
import {
|
||||||
normalizeLocalReconciliationSecretConfigPlanCommand,
|
normalizeLocalReconciliationSecretConfigPlanCommand,
|
||||||
normalizeLocalReconciliationSecretConfigVerifyCommand,
|
normalizeLocalReconciliationSecretConfigVerifyCommand,
|
||||||
@@ -75,6 +83,12 @@ interface SecretConfigReviewAuthority {
|
|||||||
readonly planTerminal: ReturnType<typeof readLocalReconciliationPlanTerminal>;
|
readonly planTerminal: ReturnType<typeof readLocalReconciliationPlanTerminal>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutomationTargetAuthority {
|
||||||
|
readonly databasePath: string;
|
||||||
|
readonly profile: 'edge' | 'standalone';
|
||||||
|
readonly snapshotSha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
function configurationError(message: string, cause?: unknown): never {
|
function configurationError(message: string, cause?: unknown): never {
|
||||||
throw new LocalDeploymentConfigurationError(
|
throw new LocalDeploymentConfigurationError(
|
||||||
`reconciliation secret config ${message}`,
|
`reconciliation secret config ${message}`,
|
||||||
@@ -304,14 +318,16 @@ function validateApplicationBinding(
|
|||||||
terminal.plan.applicationId !== command.request.applicationId ||
|
terminal.plan.applicationId !== command.request.applicationId ||
|
||||||
terminal.plan.applicationPlanDigest !==
|
terminal.plan.applicationPlanDigest !==
|
||||||
command.request.expectedApplicationPlanDigest ||
|
command.request.expectedApplicationPlanDigest ||
|
||||||
terminal.head.state !== 'reconciliation_application_planned' ||
|
|
||||||
terminal.head.sourceRecordDigest !== terminal.plan.applicationPlanDigest ||
|
|
||||||
!secretConfig ||
|
!secretConfig ||
|
||||||
secretConfig.action !== 'manual_external' ||
|
secretConfig.action !== 'manual_external' ||
|
||||||
|
terminal.head.state !== priorState ||
|
||||||
|
terminal.head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
head.state !== priorState ||
|
head.state !== priorState ||
|
||||||
head.headDigest !== command.request.expectedHeadDigest ||
|
head.headDigest !== command.request.expectedHeadDigest ||
|
||||||
(priorState === 'reconciliation_application_planned' &&
|
(priorState === 'reconciliation_application_planned' &&
|
||||||
head.sourceRecordDigest !== terminal.plan.applicationPlanDigest) ||
|
(terminal.head.sourceRecordDigest !==
|
||||||
|
terminal.plan.applicationPlanDigest ||
|
||||||
|
head.sourceRecordDigest !== terminal.plan.applicationPlanDigest)) ||
|
||||||
command.request.preparedAtMs < terminal.plan.committedAtMs ||
|
command.request.preparedAtMs < terminal.plan.committedAtMs ||
|
||||||
command.request.preparedAtMs < head.updatedAtMs
|
command.request.preparedAtMs < head.updatedAtMs
|
||||||
) {
|
) {
|
||||||
@@ -319,6 +335,176 @@ function validateApplicationBinding(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function automationTargetAuthority(
|
||||||
|
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||||
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
|
head: Readonly<LocalCutoverInstanceHead>,
|
||||||
|
uid: number,
|
||||||
|
): Promise<Readonly<AutomationTargetAuthority> | null> {
|
||||||
|
if (expectedPriorState(terminal) === 'reconciliation_application_planned') {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== 1 ||
|
||||||
|
command.request.automation !== undefined
|
||||||
|
) {
|
||||||
|
configurationError('Automation target authority is unexpected');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== 2 ||
|
||||||
|
!('automationApplyRoot' in command.options) ||
|
||||||
|
command.request.automation === undefined
|
||||||
|
) {
|
||||||
|
configurationError('Automation target authority is required');
|
||||||
|
}
|
||||||
|
const automation = command.request.automation;
|
||||||
|
const automationApplyRoot = command.options.automationApplyRoot;
|
||||||
|
validatePrivateDirectory(automationApplyRoot, uid, 'automationApplyRoot');
|
||||||
|
const selected = localReconciliationAutomationApplyPaths(
|
||||||
|
automationApplyRoot,
|
||||||
|
automation.automationId,
|
||||||
|
);
|
||||||
|
const intent = readLocalReconciliationAutomationApplyIntent(selected, uid);
|
||||||
|
const receipt = readLocalReconciliationAutomationApplyReceipt(selected, uid);
|
||||||
|
if (
|
||||||
|
intent.command.options.automationApplyRoot !== automationApplyRoot ||
|
||||||
|
intent.command.options.deploymentRoot !== command.options.deploymentRoot ||
|
||||||
|
intent.command.options.applicationRoot !==
|
||||||
|
command.options.applicationRoot ||
|
||||||
|
intent.command.request.automationId !== automation.automationId ||
|
||||||
|
intent.command.request.decisionId !== automation.decisionId ||
|
||||||
|
intent.projectId !== command.request.projectId ||
|
||||||
|
receipt.automationId !== automation.automationId ||
|
||||||
|
receipt.decisionId !== automation.decisionId ||
|
||||||
|
receipt.applyDigest !== automation.expectedApplyDigest ||
|
||||||
|
receipt.applyDigest !== head.sourceRecordDigest ||
|
||||||
|
receipt.appliedAtMs > command.request.preparedAtMs
|
||||||
|
) {
|
||||||
|
configurationError('Automation target authority is detached');
|
||||||
|
}
|
||||||
|
const verified = await verifyLocalReconciliationAutomationApply({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.automation.apply.verify',
|
||||||
|
options: intent.command.options,
|
||||||
|
request: {
|
||||||
|
decisionId: automation.decisionId,
|
||||||
|
automationId: automation.automationId,
|
||||||
|
expectedApplyDigest: automation.expectedApplyDigest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
verified.state !== 'reconciliation_automation_applied' ||
|
||||||
|
verified.applyDigest !== automation.expectedApplyDigest ||
|
||||||
|
verified.instanceHeadDigest !== head.headDigest
|
||||||
|
) {
|
||||||
|
configurationError('Automation target authority did not verify');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
databasePath: intent.command.options.targetDatabasePath,
|
||||||
|
profile: intent.profile,
|
||||||
|
snapshotSha256: receipt.targetAfter.sha256,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureReadOnlyTarget(
|
||||||
|
client: DatabaseSync,
|
||||||
|
profile: 'edge' | 'standalone',
|
||||||
|
): void {
|
||||||
|
const cacheKiB = profile === 'edge' ? 2_048 : 8_192;
|
||||||
|
client.enableDefensive(true);
|
||||||
|
client.exec(
|
||||||
|
`PRAGMA trusted_schema = OFF; PRAGMA query_only = ON; PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0; PRAGMA cache_size = -${cacheKiB}`,
|
||||||
|
);
|
||||||
|
const trustedSchema = client.prepare('PRAGMA trusted_schema').get() as
|
||||||
|
| { readonly trusted_schema?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const queryOnly = client.prepare('PRAGMA query_only').get() as
|
||||||
|
| { readonly query_only?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const tempStore = client.prepare('PRAGMA temp_store').get() as
|
||||||
|
| { readonly temp_store?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const mmapSize = client.prepare('PRAGMA mmap_size').get() as
|
||||||
|
| { readonly mmap_size?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const cacheSize = client.prepare('PRAGMA cache_size').get() as
|
||||||
|
| { readonly cache_size?: unknown }
|
||||||
|
| undefined;
|
||||||
|
if (
|
||||||
|
trustedSchema?.trusted_schema !== 0 ||
|
||||||
|
queryOnly?.query_only !== 1 ||
|
||||||
|
tempStore?.temp_store !== 2 ||
|
||||||
|
mmapSize?.mmap_size !== 0 ||
|
||||||
|
cacheSize?.cache_size !== -cacheKiB
|
||||||
|
) {
|
||||||
|
configurationError('Automation target read-only configuration drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withAutomationTargetDatabase<T>(
|
||||||
|
authority: Readonly<AutomationTargetAuthority>,
|
||||||
|
uid: number,
|
||||||
|
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||||
|
read: (database: DatabaseSync) => T,
|
||||||
|
): Promise<T> {
|
||||||
|
const beforeStat = fs.lstatSync(authority.databasePath, { bigint: true });
|
||||||
|
if (
|
||||||
|
!beforeStat.isFile() ||
|
||||||
|
beforeStat.isSymbolicLink() ||
|
||||||
|
Number(beforeStat.uid) !== uid ||
|
||||||
|
![0o600, 0o400].includes(Number(beforeStat.mode) & 0o777) ||
|
||||||
|
beforeStat.nlink !== 1n ||
|
||||||
|
fs.realpathSync(authority.databasePath) !== authority.databasePath
|
||||||
|
) {
|
||||||
|
configurationError('Automation target database identity is invalid');
|
||||||
|
}
|
||||||
|
const before = await inspectLocalSqliteSnapshot({
|
||||||
|
databasePath: authority.databasePath,
|
||||||
|
profile: authority.profile,
|
||||||
|
});
|
||||||
|
if (before.sha256 !== authority.snapshotSha256) {
|
||||||
|
configurationError('Automation target database snapshot drifted');
|
||||||
|
}
|
||||||
|
const cacheKiB = authority.profile === 'edge' ? 2_048 : 8_192;
|
||||||
|
dependencies.beforeDatabaseOpen?.('target', 'wal_shm_readonly', cacheKiB);
|
||||||
|
let client: DatabaseSync | undefined;
|
||||||
|
let output: T;
|
||||||
|
try {
|
||||||
|
client = new DatabaseSync(authority.databasePath, {
|
||||||
|
allowExtension: false,
|
||||||
|
defensive: true,
|
||||||
|
enableDoubleQuotedStringLiterals: false,
|
||||||
|
enableForeignKeyConstraints: true,
|
||||||
|
readOnly: true,
|
||||||
|
timeout: 0,
|
||||||
|
});
|
||||||
|
configureReadOnlyTarget(client, authority.profile);
|
||||||
|
output = read(client);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||||
|
return configurationError('Automation target database read failed', error);
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
dependencies.afterDatabaseClose?.('target');
|
||||||
|
}
|
||||||
|
const afterStat = fs.lstatSync(authority.databasePath, { bigint: true });
|
||||||
|
const after = await inspectLocalSqliteSnapshot({
|
||||||
|
databasePath: authority.databasePath,
|
||||||
|
profile: authority.profile,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
afterStat.dev !== beforeStat.dev ||
|
||||||
|
afterStat.ino !== beforeStat.ino ||
|
||||||
|
afterStat.mtimeNs !== beforeStat.mtimeNs ||
|
||||||
|
afterStat.ctimeNs !== beforeStat.ctimeNs ||
|
||||||
|
after.sha256 !== before.sha256 ||
|
||||||
|
after.sha256 !== authority.snapshotSha256
|
||||||
|
) {
|
||||||
|
configurationError('Automation target database changed while reading');
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
function reviewAuthority(
|
function reviewAuthority(
|
||||||
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||||
@@ -390,7 +576,9 @@ function reviewAuthority(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (opened === null) {
|
if (opened === null) {
|
||||||
configurationError('manual-required SQLite topology cannot be adapted');
|
configurationError(
|
||||||
|
'manual-required SQLite topology cannot be adapted',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -426,6 +614,7 @@ function publishPlan(
|
|||||||
authority: Readonly<SecretConfigReviewAuthority>,
|
authority: Readonly<SecretConfigReviewAuthority>,
|
||||||
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||||
uid: number,
|
uid: number,
|
||||||
|
automationTarget?: DatabaseSync,
|
||||||
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||||
let descriptor: number | undefined;
|
let descriptor: number | undefined;
|
||||||
let createdStage = false;
|
let createdStage = false;
|
||||||
@@ -463,12 +652,7 @@ function publishPlan(
|
|||||||
preparedHeadDigest: head.headDigest,
|
preparedHeadDigest: head.headDigest,
|
||||||
preparedAtMs: command.request.preparedAtMs,
|
preparedAtMs: command.request.preparedAtMs,
|
||||||
});
|
});
|
||||||
const generated = withLocalReconciliationSealedDatabase(
|
const generate = (target: DatabaseSync) =>
|
||||||
authority.planTerminal.bundle,
|
|
||||||
'target',
|
|
||||||
uid,
|
|
||||||
dependencies,
|
|
||||||
(target) =>
|
|
||||||
withLocalReconciliationSealedDatabase(
|
withLocalReconciliationSealedDatabase(
|
||||||
authority.planTerminal.bundle,
|
authority.planTerminal.bundle,
|
||||||
'legacy',
|
'legacy',
|
||||||
@@ -482,8 +666,17 @@ function publishPlan(
|
|||||||
legacy,
|
legacy,
|
||||||
target,
|
target,
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
const generated =
|
||||||
|
automationTarget === undefined
|
||||||
|
? withLocalReconciliationSealedDatabase(
|
||||||
|
authority.planTerminal.bundle,
|
||||||
|
'target',
|
||||||
|
uid,
|
||||||
|
dependencies,
|
||||||
|
generate,
|
||||||
|
)
|
||||||
|
: generate(automationTarget);
|
||||||
if (generated === null || generated === undefined) {
|
if (generated === null || generated === undefined) {
|
||||||
configurationError('manual-required SQLite topology cannot be planned');
|
configurationError('manual-required SQLite topology cannot be planned');
|
||||||
}
|
}
|
||||||
@@ -577,7 +770,10 @@ function sealDirectory(directory: string, uid: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sealTerminal(selected: Readonly<SecretConfigPaths>, uid: number): void {
|
function sealTerminal(
|
||||||
|
selected: Readonly<SecretConfigPaths>,
|
||||||
|
uid: number,
|
||||||
|
): void {
|
||||||
if (fs.readdirSync(selected.staging).length !== 0) {
|
if (fs.readdirSync(selected.staging).length !== 0) {
|
||||||
configurationError('staging must be empty before terminal seal');
|
configurationError('staging must be empty before terminal seal');
|
||||||
}
|
}
|
||||||
@@ -679,11 +875,17 @@ export async function planLocalReconciliationSecretConfig(
|
|||||||
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||||
const command = normalizeLocalReconciliationSecretConfigPlanCommand(value);
|
const command = normalizeLocalReconciliationSecretConfigPlanCommand(value);
|
||||||
const identity = currentIdentity();
|
const identity = currentIdentity();
|
||||||
for (const [directory, label] of [
|
const authorityDirectories: readonly (readonly [string, string])[] = [
|
||||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||||
[command.options.applicationRoot, 'applicationRoot'],
|
[command.options.applicationRoot, 'applicationRoot'],
|
||||||
[command.options.secretConfigRoot, 'secretConfigRoot'],
|
[command.options.secretConfigRoot, 'secretConfigRoot'],
|
||||||
] as const) {
|
...(command.schemaVersion === 2 && 'automationApplyRoot' in command.options
|
||||||
|
? ([
|
||||||
|
[command.options.automationApplyRoot, 'automationApplyRoot'],
|
||||||
|
] as const)
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
for (const [directory, label] of authorityDirectories) {
|
||||||
validatePrivateDirectory(directory, identity.uid, label);
|
validatePrivateDirectory(directory, identity.uid, label);
|
||||||
}
|
}
|
||||||
const terminal = await readLocalReconciliationApplicationTerminal(
|
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||||
@@ -710,11 +912,7 @@ export async function planLocalReconciliationSecretConfig(
|
|||||||
);
|
);
|
||||||
validateCatalog(selected, false);
|
validateCatalog(selected, false);
|
||||||
const receipt = readReceipt(selected.receipt, identity.uid, [0o600, 0o400]);
|
const receipt = readReceipt(selected.receipt, identity.uid, [0o600, 0o400]);
|
||||||
validateTerminalBinding(
|
validateTerminalBinding(receipt, terminal, command.request.secretConfigId);
|
||||||
receipt,
|
|
||||||
terminal,
|
|
||||||
command.request.secretConfigId,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
receipt.applicationPlanDigest !==
|
receipt.applicationPlanDigest !==
|
||||||
command.request.expectedApplicationPlanDigest ||
|
command.request.expectedApplicationPlanDigest ||
|
||||||
@@ -730,7 +928,10 @@ export async function planLocalReconciliationSecretConfig(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
);
|
);
|
||||||
const existing = head.state === 'reconciliation_secret_config_planned';
|
const existing = head.state === 'reconciliation_secret_config_planned';
|
||||||
if (!existing) validateApplicationBinding(command, terminal, head);
|
if (!existing) {
|
||||||
|
validateApplicationBinding(command, terminal, head);
|
||||||
|
await automationTargetAuthority(command, terminal, head, identity.uid);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
existing &&
|
existing &&
|
||||||
head.sourceRecordDigest !== receipt.secretConfigPlanDigest
|
head.sourceRecordDigest !== receipt.secretConfigPlanDigest
|
||||||
@@ -754,6 +955,12 @@ export async function planLocalReconciliationSecretConfig(
|
|||||||
identity.uid,
|
identity.uid,
|
||||||
);
|
);
|
||||||
validateApplicationBinding(command, terminal, head);
|
validateApplicationBinding(command, terminal, head);
|
||||||
|
const automationTarget = await automationTargetAuthority(
|
||||||
|
command,
|
||||||
|
terminal,
|
||||||
|
head,
|
||||||
|
identity.uid,
|
||||||
|
);
|
||||||
const authority = reviewAuthority(
|
const authority = reviewAuthority(
|
||||||
command,
|
command,
|
||||||
terminal,
|
terminal,
|
||||||
@@ -771,7 +978,8 @@ export async function planLocalReconciliationSecretConfig(
|
|||||||
'secretConfigPlanStaging',
|
'secretConfigPlanStaging',
|
||||||
);
|
);
|
||||||
validateCatalog(selected, false);
|
validateCatalog(selected, false);
|
||||||
const receipt = publishPlan(
|
const publish = (target?: DatabaseSync) =>
|
||||||
|
publishPlan(
|
||||||
selected,
|
selected,
|
||||||
command,
|
command,
|
||||||
terminal,
|
terminal,
|
||||||
@@ -779,6 +987,16 @@ export async function planLocalReconciliationSecretConfig(
|
|||||||
authority,
|
authority,
|
||||||
dependencies,
|
dependencies,
|
||||||
identity.uid,
|
identity.uid,
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
const receipt =
|
||||||
|
automationTarget === null
|
||||||
|
? publish()
|
||||||
|
: await withAutomationTargetDatabase(
|
||||||
|
automationTarget,
|
||||||
|
identity.uid,
|
||||||
|
dependencies,
|
||||||
|
publish,
|
||||||
);
|
);
|
||||||
dependencies.afterPlanPublished?.();
|
dependencies.afterPlanPublished?.();
|
||||||
authority.confirmDecisionFileIdentity();
|
authority.confirmDecisionFileIdentity();
|
||||||
@@ -813,8 +1031,18 @@ export async function verifyLocalReconciliationSecretConfigPlan(
|
|||||||
command.options.secretConfigRoot,
|
command.options.secretConfigRoot,
|
||||||
command.request.secretConfigId,
|
command.request.secretConfigId,
|
||||||
);
|
);
|
||||||
validateDirectory(selected.root, identity.uid, [0o500], 'Secret/Config plan root');
|
validateDirectory(
|
||||||
validateDirectory(selected.staging, identity.uid, [0o500], 'Secret/Config staging');
|
selected.root,
|
||||||
|
identity.uid,
|
||||||
|
[0o500],
|
||||||
|
'Secret/Config plan root',
|
||||||
|
);
|
||||||
|
validateDirectory(
|
||||||
|
selected.staging,
|
||||||
|
identity.uid,
|
||||||
|
[0o500],
|
||||||
|
'Secret/Config staging',
|
||||||
|
);
|
||||||
validateCatalog(selected, true);
|
validateCatalog(selected, true);
|
||||||
const receipt = readReceipt(selected.receipt, identity.uid, [0o400]);
|
const receipt = readReceipt(selected.receipt, identity.uid, [0o400]);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -887,6 +887,97 @@ function automationReadyDatabaseInitializer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function crossDomainReconciliationDatabaseInitializer() {
|
||||||
|
return ({ legacySourcePath, recoveryPath, targetDatabasePath }) => {
|
||||||
|
const legacy = new DatabaseSync(legacySourcePath);
|
||||||
|
legacy.exec(`
|
||||||
|
CREATE TABLE "Crontabs" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
command TEXT,
|
||||||
|
schedule TEXT,
|
||||||
|
saved INTEGER,
|
||||||
|
isSystem INTEGER,
|
||||||
|
isDisabled INTEGER,
|
||||||
|
isPinned INTEGER,
|
||||||
|
labels TEXT,
|
||||||
|
sub_id INTEGER,
|
||||||
|
extra_schedules TEXT,
|
||||||
|
task_before TEXT,
|
||||||
|
task_after TEXT,
|
||||||
|
log_name TEXT,
|
||||||
|
allow_multiple_instances INTEGER,
|
||||||
|
work_dir TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE "Envs" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
value TEXT,
|
||||||
|
status INTEGER,
|
||||||
|
position REAL,
|
||||||
|
"isPinned" INTEGER,
|
||||||
|
"createdAt" TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE "CrontabStats" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ref_id INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
run_count INTEGER,
|
||||||
|
success_count INTEGER,
|
||||||
|
fail_count INTEGER,
|
||||||
|
total_time INTEGER,
|
||||||
|
max_time INTEGER,
|
||||||
|
"createdAt" TEXT NOT NULL,
|
||||||
|
"updatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE "RunningInstances" (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
cron_id INTEGER NOT NULL,
|
||||||
|
run_id TEXT,
|
||||||
|
attempt_id TEXT,
|
||||||
|
pid INTEGER,
|
||||||
|
log_path TEXT,
|
||||||
|
started_at INTEGER NOT NULL,
|
||||||
|
finished_at INTEGER,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
exit_code INTEGER,
|
||||||
|
"createdAt" TEXT NOT NULL,
|
||||||
|
"updatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "Crontabs" (
|
||||||
|
id, name, command, schedule, saved, isSystem, isDisabled, isPinned
|
||||||
|
) VALUES (1, 'nightly', 'task nightly.js', '0 0 * * *', 1, 0, 0, 0);
|
||||||
|
INSERT INTO "Envs" (
|
||||||
|
id, name, value, status, position, "isPinned", "createdAt"
|
||||||
|
) VALUES (1, 'ACTIVE_TOKEN', 'private-secret-value', 0, 1, 0, '2026-01-01');
|
||||||
|
INSERT INTO "CrontabStats" (
|
||||||
|
id, ref_id, date, run_count, success_count, fail_count,
|
||||||
|
total_time, max_time, "createdAt", "updatedAt"
|
||||||
|
) VALUES (1, 1, '2026-01-01', 1, 1, 0, 100, 100, '2026-01-01', '2026-01-01');
|
||||||
|
`);
|
||||||
|
legacy.close();
|
||||||
|
fs.chmodSync(legacySourcePath, 0o600);
|
||||||
|
fs.copyFileSync(legacySourcePath, recoveryPath);
|
||||||
|
fs.chmodSync(recoveryPath, 0o600);
|
||||||
|
fs.copyFileSync(legacySourcePath, targetDatabasePath);
|
||||||
|
fs.chmodSync(targetDatabasePath, 0o600);
|
||||||
|
|
||||||
|
const migration = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`require('@qinglong/local-sqlite/migration')
|
||||||
|
.migrateLocalSqlitePath({ databasePath: process.argv[1], profile: 'edge' })
|
||||||
|
.catch((error) => { console.error(error); process.exitCode = 1; });`,
|
||||||
|
targetDatabasePath,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', cwd: path.join(__dirname, '..') },
|
||||||
|
);
|
||||||
|
assert.equal(migration.status, 0, migration.stderr);
|
||||||
|
insertSecretConfigOwnerBinding(targetDatabasePath);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function insertSecretConfigOwnerBinding(targetDatabasePath) {
|
function insertSecretConfigOwnerBinding(targetDatabasePath) {
|
||||||
const target = new DatabaseSync(targetDatabasePath);
|
const target = new DatabaseSync(targetDatabasePath);
|
||||||
target.exec(`
|
target.exec(`
|
||||||
@@ -1416,10 +1507,12 @@ async function plannedAutomationFixture(t, options = {}) {
|
|||||||
createDefaultSidecars: false,
|
createDefaultSidecars: false,
|
||||||
targetInsideDeploymentRoot: true,
|
targetInsideDeploymentRoot: true,
|
||||||
initializeDatabases:
|
initializeDatabases:
|
||||||
options.readyTarget === true
|
options.initializeDatabases ??
|
||||||
|
(options.readyTarget === true
|
||||||
? automationReadyDatabaseInitializer()
|
? automationReadyDatabaseInitializer()
|
||||||
: automationDatabaseInitializer(),
|
: automationDatabaseInitializer()),
|
||||||
mutateTarget(paths) {
|
mutateTarget(paths) {
|
||||||
|
if (options.mutateTarget) return options.mutateTarget(paths);
|
||||||
return options.readyTarget === true
|
return options.readyTarget === true
|
||||||
? mutateReadyAutomationTarget(paths)
|
? mutateReadyAutomationTarget(paths)
|
||||||
: mutateAutomationTarget(paths, options.occupied === true);
|
: mutateAutomationTarget(paths, options.occupied === true);
|
||||||
@@ -1438,6 +1531,7 @@ async function plannedAutomationFixture(t, options = {}) {
|
|||||||
options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
||||||
selected.reason =
|
selected.reason =
|
||||||
options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
||||||
|
options.mutateDecisions?.(records);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const preparedApplication = await prepareLocalReconciliationApplication(
|
const preparedApplication = await prepareLocalReconciliationApplication(
|
||||||
@@ -1839,6 +1933,9 @@ async function appliedAutomationFixture(t, options = {}) {
|
|||||||
automationId:
|
automationId:
|
||||||
options.automationId ?? '00000000-0000-4000-8000-000000000484',
|
options.automationId ?? '00000000-0000-4000-8000-000000000484',
|
||||||
readyTarget: true,
|
readyTarget: true,
|
||||||
|
initializeDatabases: options.initializeDatabases,
|
||||||
|
mutateTarget: options.mutateTarget,
|
||||||
|
mutateDecisions: options.mutateDecisions,
|
||||||
});
|
});
|
||||||
assert.equal(state.application.outcome, 'adapter_and_manual_required');
|
assert.equal(state.application.outcome, 'adapter_and_manual_required');
|
||||||
const decisionId =
|
const decisionId =
|
||||||
@@ -4008,6 +4105,169 @@ test('Secret/Config plan keeps active Env and unknown Configs manual', async (t)
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Secret/Config plan follows applied Automation and preserved Run History on an adopted legacy target', async (t) => {
|
||||||
|
const state = await appliedAutomationFixture(t, {
|
||||||
|
suffix: 'cross-domain-secret-config-plan',
|
||||||
|
planId: '00000000-0000-4000-8000-000000000425',
|
||||||
|
reviewId: '00000000-0000-4000-8000-000000000426',
|
||||||
|
applicationId: '00000000-0000-4000-8000-000000000427',
|
||||||
|
automationId: '00000000-0000-4000-8000-000000000428',
|
||||||
|
decisionId: '019b0000-0000-7000-8000-000000000425',
|
||||||
|
mutationId: '00000000-0000-4000-8000-000000000429',
|
||||||
|
initializeDatabases: crossDomainReconciliationDatabaseInitializer(),
|
||||||
|
mutateDecisions(records) {
|
||||||
|
let secretConfigCount = 0;
|
||||||
|
let runHistoryCount = 0;
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.kind !== 'qinglong3-local-reconciliation-review-decision') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (record.domain === 'secret_and_config') {
|
||||||
|
record.disposition = 'manual_external';
|
||||||
|
record.reason = 'external_recovery_required';
|
||||||
|
secretConfigCount += 1;
|
||||||
|
} else if (
|
||||||
|
record.database === 'legacy' &&
|
||||||
|
record.domain === 'run_history'
|
||||||
|
) {
|
||||||
|
record.disposition = 'retain_both';
|
||||||
|
record.reason = 'preserve_both';
|
||||||
|
runHistoryCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(secretConfigCount > 0);
|
||||||
|
assert.ok(runHistoryCount > 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(state.application.outcome, 'adapter_and_manual_required');
|
||||||
|
|
||||||
|
const runHistoryRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
'cross-domain-run-history',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(runHistoryRoot, { mode: 0o700 });
|
||||||
|
const preservationCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.run-history.preserve',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
runHistoryRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
preservationId: '00000000-0000-4000-8000-000000000430',
|
||||||
|
applicationId: state.application.applicationId,
|
||||||
|
expectedApplicationPlanDigest: state.application.applicationPlanDigest,
|
||||||
|
expectedHeadDigest: state.applied.instanceHeadDigest,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
preservedAtMs: state.applyCommand.request.appliedAtMs + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const preserved = await preserveLocalReconciliationRunHistory(
|
||||||
|
preservationCommand,
|
||||||
|
);
|
||||||
|
assert.ok(preserved.legacyFactCount > 0);
|
||||||
|
assert.ok(preserved.targetFactCount > 0);
|
||||||
|
const preservationVerified = await verifyLocalReconciliationRunHistory({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.run-history.verify',
|
||||||
|
options: preservationCommand.options,
|
||||||
|
request: {
|
||||||
|
preservationId: preservationCommand.request.preservationId,
|
||||||
|
applicationId: preservationCommand.request.applicationId,
|
||||||
|
expectedPreservationDigest: preserved.preservationDigest,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(preservationVerified.status, 'verified');
|
||||||
|
|
||||||
|
const secretConfigRoot = path.join(
|
||||||
|
path.dirname(state.captureRoot),
|
||||||
|
'cross-domain-secret-config-plan',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(secretConfigRoot, { mode: 0o700 });
|
||||||
|
const secretConfigId = '00000000-0000-4000-8000-000000000431';
|
||||||
|
const secretConfigCommand = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.plan',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
secretConfigRoot,
|
||||||
|
automationApplyRoot: state.automationApplyRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
secretConfigId,
|
||||||
|
applicationId: state.application.applicationId,
|
||||||
|
expectedApplicationPlanDigest: state.application.applicationPlanDigest,
|
||||||
|
expectedHeadDigest: state.applied.instanceHeadDigest,
|
||||||
|
decisionFilePath: state.reviewFile.filePath,
|
||||||
|
projectId: 'default',
|
||||||
|
preparedAtMs: preservationCommand.request.preservedAtMs + 1,
|
||||||
|
automation: {
|
||||||
|
automationId: state.automationCommand.request.automationId,
|
||||||
|
decisionId: state.decisionId,
|
||||||
|
expectedApplyDigest: state.applied.applyDigest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const legacyPlanCommand = {
|
||||||
|
...secretConfigCommand,
|
||||||
|
schemaVersion: 1,
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
secretConfigRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
...secretConfigCommand.request,
|
||||||
|
automation: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
delete legacyPlanCommand.request.automation;
|
||||||
|
await assert.rejects(
|
||||||
|
planLocalReconciliationSecretConfig(legacyPlanCommand),
|
||||||
|
/Automation target authority is required/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
planLocalReconciliationSecretConfig({
|
||||||
|
...secretConfigCommand,
|
||||||
|
request: {
|
||||||
|
...secretConfigCommand.request,
|
||||||
|
automation: {
|
||||||
|
...secretConfigCommand.request.automation,
|
||||||
|
expectedApplyDigest: 'f'.repeat(64),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Automation target authority is detached/,
|
||||||
|
);
|
||||||
|
const planned = await planLocalReconciliationSecretConfig(
|
||||||
|
secretConfigCommand,
|
||||||
|
);
|
||||||
|
assert.equal(planned.outcome, 'ready');
|
||||||
|
assert.equal(planned.eligibleBindingCount, 1);
|
||||||
|
assert.equal(planned.adoptedLegacyTaskCount, 1);
|
||||||
|
const verified = await verifyLocalReconciliationSecretConfigPlan({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'local.deployment.reconciliation.secret-config.verify',
|
||||||
|
options: {
|
||||||
|
deploymentRoot: state.deploymentRoot,
|
||||||
|
applicationRoot: state.applicationRoot,
|
||||||
|
secretConfigRoot,
|
||||||
|
allowRootService: rootAcknowledgement(),
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
secretConfigId,
|
||||||
|
expectedSecretConfigPlanDigest: planned.secretConfigPlanDigest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(verified.status, 'verified');
|
||||||
|
});
|
||||||
|
|
||||||
test('Secret/Config decision reauthenticates the same reviewer, seals exact candidates and verifies content-free', async (t) => {
|
test('Secret/Config decision reauthenticates the same reviewer, seals exact candidates and verifies content-free', async (t) => {
|
||||||
const state = await plannedSecretConfigDecisionFixture(t, {
|
const state = await plannedSecretConfigDecisionFixture(t, {
|
||||||
suffix: 'decision-terminal',
|
suffix: 'decision-terminal',
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ EOF
|
|||||||
|
|
||||||
secret_prepared_ms=$((preserved_ms + 1))
|
secret_prepared_ms=$((preserved_ms + 1))
|
||||||
cat >"$command_root/secret-config-plan.json" <<EOF
|
cat >"$command_root/secret-config-plan.json" <<EOF
|
||||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.secret-config.plan","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","secretConfigRoot":"$secret_config_root","allowRootService":$allow_root_service},"request":{"secretConfigId":"$SECRET_CONFIG_ID","applicationId":"$APPLICATION_ID","expectedApplicationPlanDigest":"$application_plan_digest","expectedHeadDigest":"$applied_head_digest","decisionFilePath":"$secondary_input","projectId":"default","preparedAtMs":$secret_prepared_ms}}
|
{"schemaVersion":2,"operation":"local.deployment.reconciliation.secret-config.plan","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","secretConfigRoot":"$secret_config_root","automationApplyRoot":"$automation_apply_root","allowRootService":$allow_root_service},"request":{"secretConfigId":"$SECRET_CONFIG_ID","applicationId":"$APPLICATION_ID","expectedApplicationPlanDigest":"$application_plan_digest","expectedHeadDigest":"$applied_head_digest","decisionFilePath":"$secondary_input","projectId":"default","preparedAtMs":$secret_prepared_ms,"automation":{"automationId":"$AUTOMATION_ID","decisionId":"$AUTOMATION_DECISION_ID","expectedApplyDigest":"$apply_digest"}}}
|
||||||
EOF
|
EOF
|
||||||
chmod 0600 "$command_root/secret-config-plan.json"
|
chmod 0600 "$command_root/secret-config-plan.json"
|
||||||
phase 'materialize reviewed Secret/Config candidate plan'
|
phase 'materialize reviewed Secret/Config candidate plan'
|
||||||
|
|||||||
Reference in New Issue
Block a user