mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +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;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigAutomationOptions
|
||||
extends LocalReconciliationSecretConfigOptions {
|
||||
readonly automationApplyRoot: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigAutomationBinding {
|
||||
readonly automationId: string;
|
||||
readonly decisionId: string;
|
||||
readonly expectedApplyDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationSecretConfigPlanCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly operation: 'local.deployment.reconciliation.secret-config.plan';
|
||||
readonly options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||
readonly options: Readonly<
|
||||
| LocalReconciliationSecretConfigOptions
|
||||
| LocalReconciliationSecretConfigAutomationOptions
|
||||
>;
|
||||
readonly request: Readonly<{
|
||||
secretConfigId: string;
|
||||
applicationId: string;
|
||||
@@ -28,6 +42,7 @@ export interface LocalReconciliationSecretConfigPlanCommand {
|
||||
decisionFilePath: string;
|
||||
projectId: string;
|
||||
preparedAtMs: number;
|
||||
automation?: Readonly<LocalReconciliationSecretConfigAutomationBinding>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -146,13 +161,18 @@ function projectId(value: unknown): string {
|
||||
|
||||
function normalizeOptions(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigOptions> {
|
||||
schemaVersion: 1 | 2,
|
||||
): Readonly<
|
||||
| LocalReconciliationSecretConfigOptions
|
||||
| LocalReconciliationSecretConfigAutomationOptions
|
||||
> {
|
||||
const options = object(value, 'options');
|
||||
exact(
|
||||
options,
|
||||
[
|
||||
'allowRootService',
|
||||
'applicationRoot',
|
||||
...(schemaVersion === 2 ? ['automationApplyRoot'] : []),
|
||||
'deploymentRoot',
|
||||
'secretConfigRoot',
|
||||
],
|
||||
@@ -169,6 +189,9 @@ function normalizeOptions(
|
||||
safePath(options.deploymentRoot, 'deploymentRoot'),
|
||||
safePath(options.applicationRoot, 'applicationRoot'),
|
||||
safePath(options.secretConfigRoot, 'secretConfigRoot'),
|
||||
...(schemaVersion === 2
|
||||
? [safePath(options.automationApplyRoot, 'automationApplyRoot')]
|
||||
: []),
|
||||
];
|
||||
for (let left = 0; left < roots.length; left += 1) {
|
||||
for (let right = left + 1; right < roots.length; right += 1) {
|
||||
@@ -184,6 +207,7 @@ function normalizeOptions(
|
||||
deploymentRoot: roots[0]!,
|
||||
applicationRoot: roots[1]!,
|
||||
secretConfigRoot: roots[2]!,
|
||||
...(schemaVersion === 2 ? { automationApplyRoot: roots[3]! } : {}),
|
||||
allowRootService: options.allowRootService,
|
||||
});
|
||||
}
|
||||
@@ -194,7 +218,11 @@ function command(
|
||||
| LocalReconciliationSecretConfigPlanCommand['operation']
|
||||
| LocalReconciliationSecretConfigVerifyCommand['operation'],
|
||||
): Readonly<{
|
||||
options: Readonly<LocalReconciliationSecretConfigOptions>;
|
||||
schemaVersion: 1 | 2;
|
||||
options: Readonly<
|
||||
| LocalReconciliationSecretConfigOptions
|
||||
| LocalReconciliationSecretConfigAutomationOptions
|
||||
>;
|
||||
request: Record<string, unknown>;
|
||||
}> {
|
||||
const selected = object(value, 'command');
|
||||
@@ -203,15 +231,47 @@ function command(
|
||||
['operation', 'options', 'request', 'schemaVersion'],
|
||||
'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');
|
||||
}
|
||||
return Object.freeze({
|
||||
options: normalizeOptions(selected.options),
|
||||
schemaVersion,
|
||||
options: normalizeOptions(selected.options, schemaVersion),
|
||||
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(
|
||||
value: unknown,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanCommand> {
|
||||
@@ -223,6 +283,7 @@ export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
||||
selected.request,
|
||||
[
|
||||
'applicationId',
|
||||
...(selected.schemaVersion === 2 ? ['automation'] : []),
|
||||
'decisionFilePath',
|
||||
'expectedApplicationPlanDigest',
|
||||
'expectedHeadDigest',
|
||||
@@ -255,7 +316,7 @@ export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
||||
configurationError('preparedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schemaVersion: selected.schemaVersion,
|
||||
operation: 'local.deployment.reconciliation.secret-config.plan',
|
||||
options: selected.options,
|
||||
request: Object.freeze({
|
||||
@@ -278,6 +339,9 @@ export function normalizeLocalReconciliationSecretConfigPlanCommand(
|
||||
decisionFilePath,
|
||||
projectId: projectId(selected.request.projectId),
|
||||
preparedAtMs: selected.request.preparedAtMs as number,
|
||||
...(selected.schemaVersion === 2
|
||||
? { automation: automationBinding(selected.request.automation) }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -297,7 +361,12 @@ export function normalizeLocalReconciliationSecretConfigVerifyCommand(
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
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({
|
||||
secretConfigId: identifier(
|
||||
selected.request.secretConfigId,
|
||||
|
||||
+273
-45
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import { inspectLocalSqliteSnapshot } from '@qinglong/local-sqlite/rollout-safety';
|
||||
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
@@ -27,6 +29,12 @@ import {
|
||||
readLocalReconciliationApplicationTerminal,
|
||||
type LocalReconciliationApplicationTerminal,
|
||||
} from '../coordinator';
|
||||
import { verifyLocalReconciliationAutomationApply } from '../automation/applyCoordinator';
|
||||
import {
|
||||
localReconciliationAutomationApplyPaths,
|
||||
readLocalReconciliationAutomationApplyIntent,
|
||||
readLocalReconciliationAutomationApplyReceipt,
|
||||
} from '../automation/applyStorage';
|
||||
import {
|
||||
normalizeLocalReconciliationSecretConfigPlanCommand,
|
||||
normalizeLocalReconciliationSecretConfigVerifyCommand,
|
||||
@@ -75,6 +83,12 @@ interface SecretConfigReviewAuthority {
|
||||
readonly planTerminal: ReturnType<typeof readLocalReconciliationPlanTerminal>;
|
||||
}
|
||||
|
||||
interface AutomationTargetAuthority {
|
||||
readonly databasePath: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly snapshotSha256: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation secret config ${message}`,
|
||||
@@ -304,14 +318,16 @@ function validateApplicationBinding(
|
||||
terminal.plan.applicationId !== command.request.applicationId ||
|
||||
terminal.plan.applicationPlanDigest !==
|
||||
command.request.expectedApplicationPlanDigest ||
|
||||
terminal.head.state !== 'reconciliation_application_planned' ||
|
||||
terminal.head.sourceRecordDigest !== terminal.plan.applicationPlanDigest ||
|
||||
!secretConfig ||
|
||||
secretConfig.action !== 'manual_external' ||
|
||||
terminal.head.state !== priorState ||
|
||||
terminal.head.headDigest !== command.request.expectedHeadDigest ||
|
||||
head.state !== priorState ||
|
||||
head.headDigest !== command.request.expectedHeadDigest ||
|
||||
(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 < 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(
|
||||
command: Readonly<LocalReconciliationSecretConfigPlanCommand>,
|
||||
terminal: Readonly<LocalReconciliationApplicationTerminal>,
|
||||
@@ -390,7 +576,9 @@ function reviewAuthority(
|
||||
),
|
||||
);
|
||||
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>,
|
||||
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||
uid: number,
|
||||
automationTarget?: DatabaseSync,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||
let descriptor: number | undefined;
|
||||
let createdStage = false;
|
||||
@@ -463,27 +652,31 @@ function publishPlan(
|
||||
preparedHeadDigest: head.headDigest,
|
||||
preparedAtMs: command.request.preparedAtMs,
|
||||
});
|
||||
const generated = withLocalReconciliationSealedDatabase(
|
||||
authority.planTerminal.bundle,
|
||||
'target',
|
||||
uid,
|
||||
dependencies,
|
||||
(target) =>
|
||||
withLocalReconciliationSealedDatabase(
|
||||
authority.planTerminal.bundle,
|
||||
'legacy',
|
||||
uid,
|
||||
dependencies,
|
||||
(legacy) =>
|
||||
writeLocalReconciliationSecretConfigPlan({
|
||||
descriptor: descriptor!,
|
||||
maxBytes: maxPlanBytes(terminal.plan.profile),
|
||||
header,
|
||||
legacy,
|
||||
target,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const generate = (target: DatabaseSync) =>
|
||||
withLocalReconciliationSealedDatabase(
|
||||
authority.planTerminal.bundle,
|
||||
'legacy',
|
||||
uid,
|
||||
dependencies,
|
||||
(legacy) =>
|
||||
writeLocalReconciliationSecretConfigPlan({
|
||||
descriptor: descriptor!,
|
||||
maxBytes: maxPlanBytes(terminal.plan.profile),
|
||||
header,
|
||||
legacy,
|
||||
target,
|
||||
}),
|
||||
);
|
||||
const generated =
|
||||
automationTarget === undefined
|
||||
? withLocalReconciliationSealedDatabase(
|
||||
authority.planTerminal.bundle,
|
||||
'target',
|
||||
uid,
|
||||
dependencies,
|
||||
generate,
|
||||
)
|
||||
: generate(automationTarget);
|
||||
if (generated === null || generated === undefined) {
|
||||
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) {
|
||||
configurationError('staging must be empty before terminal seal');
|
||||
}
|
||||
@@ -679,11 +875,17 @@ export async function planLocalReconciliationSecretConfig(
|
||||
): Promise<Readonly<LocalReconciliationSecretConfigPlanResult>> {
|
||||
const command = normalizeLocalReconciliationSecretConfigPlanCommand(value);
|
||||
const identity = currentIdentity();
|
||||
for (const [directory, label] of [
|
||||
const authorityDirectories: readonly (readonly [string, string])[] = [
|
||||
[command.options.deploymentRoot, 'deploymentRoot'],
|
||||
[command.options.applicationRoot, 'applicationRoot'],
|
||||
[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);
|
||||
}
|
||||
const terminal = await readLocalReconciliationApplicationTerminal(
|
||||
@@ -710,11 +912,7 @@ export async function planLocalReconciliationSecretConfig(
|
||||
);
|
||||
validateCatalog(selected, false);
|
||||
const receipt = readReceipt(selected.receipt, identity.uid, [0o600, 0o400]);
|
||||
validateTerminalBinding(
|
||||
receipt,
|
||||
terminal,
|
||||
command.request.secretConfigId,
|
||||
);
|
||||
validateTerminalBinding(receipt, terminal, command.request.secretConfigId);
|
||||
if (
|
||||
receipt.applicationPlanDigest !==
|
||||
command.request.expectedApplicationPlanDigest ||
|
||||
@@ -730,7 +928,10 @@ export async function planLocalReconciliationSecretConfig(
|
||||
identity.uid,
|
||||
);
|
||||
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 (
|
||||
existing &&
|
||||
head.sourceRecordDigest !== receipt.secretConfigPlanDigest
|
||||
@@ -754,6 +955,12 @@ export async function planLocalReconciliationSecretConfig(
|
||||
identity.uid,
|
||||
);
|
||||
validateApplicationBinding(command, terminal, head);
|
||||
const automationTarget = await automationTargetAuthority(
|
||||
command,
|
||||
terminal,
|
||||
head,
|
||||
identity.uid,
|
||||
);
|
||||
const authority = reviewAuthority(
|
||||
command,
|
||||
terminal,
|
||||
@@ -771,15 +978,26 @@ export async function planLocalReconciliationSecretConfig(
|
||||
'secretConfigPlanStaging',
|
||||
);
|
||||
validateCatalog(selected, false);
|
||||
const receipt = publishPlan(
|
||||
selected,
|
||||
command,
|
||||
terminal,
|
||||
head,
|
||||
authority,
|
||||
dependencies,
|
||||
identity.uid,
|
||||
);
|
||||
const publish = (target?: DatabaseSync) =>
|
||||
publishPlan(
|
||||
selected,
|
||||
command,
|
||||
terminal,
|
||||
head,
|
||||
authority,
|
||||
dependencies,
|
||||
identity.uid,
|
||||
target,
|
||||
);
|
||||
const receipt =
|
||||
automationTarget === null
|
||||
? publish()
|
||||
: await withAutomationTargetDatabase(
|
||||
automationTarget,
|
||||
identity.uid,
|
||||
dependencies,
|
||||
publish,
|
||||
);
|
||||
dependencies.afterPlanPublished?.();
|
||||
authority.confirmDecisionFileIdentity();
|
||||
publishExactFile(
|
||||
@@ -813,8 +1031,18 @@ export async function verifyLocalReconciliationSecretConfigPlan(
|
||||
command.options.secretConfigRoot,
|
||||
command.request.secretConfigId,
|
||||
);
|
||||
validateDirectory(selected.root, identity.uid, [0o500], 'Secret/Config plan root');
|
||||
validateDirectory(selected.staging, identity.uid, [0o500], 'Secret/Config staging');
|
||||
validateDirectory(
|
||||
selected.root,
|
||||
identity.uid,
|
||||
[0o500],
|
||||
'Secret/Config plan root',
|
||||
);
|
||||
validateDirectory(
|
||||
selected.staging,
|
||||
identity.uid,
|
||||
[0o500],
|
||||
'Secret/Config staging',
|
||||
);
|
||||
validateCatalog(selected, true);
|
||||
const receipt = readReceipt(selected.receipt, identity.uid, [0o400]);
|
||||
if (
|
||||
|
||||
@@ -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) {
|
||||
const target = new DatabaseSync(targetDatabasePath);
|
||||
target.exec(`
|
||||
@@ -1416,10 +1507,12 @@ async function plannedAutomationFixture(t, options = {}) {
|
||||
createDefaultSidecars: false,
|
||||
targetInsideDeploymentRoot: true,
|
||||
initializeDatabases:
|
||||
options.readyTarget === true
|
||||
options.initializeDatabases ??
|
||||
(options.readyTarget === true
|
||||
? automationReadyDatabaseInitializer()
|
||||
: automationDatabaseInitializer(),
|
||||
: automationDatabaseInitializer()),
|
||||
mutateTarget(paths) {
|
||||
if (options.mutateTarget) return options.mutateTarget(paths);
|
||||
return options.readyTarget === true
|
||||
? mutateReadyAutomationTarget(paths)
|
||||
: mutateAutomationTarget(paths, options.occupied === true);
|
||||
@@ -1438,6 +1531,7 @@ async function plannedAutomationFixture(t, options = {}) {
|
||||
options.occupied === true ? 'retain_both' : 'adopt_legacy';
|
||||
selected.reason =
|
||||
options.occupied === true ? 'preserve_both' : 'prefer_legacy';
|
||||
options.mutateDecisions?.(records);
|
||||
},
|
||||
});
|
||||
const preparedApplication = await prepareLocalReconciliationApplication(
|
||||
@@ -1839,6 +1933,9 @@ async function appliedAutomationFixture(t, options = {}) {
|
||||
automationId:
|
||||
options.automationId ?? '00000000-0000-4000-8000-000000000484',
|
||||
readyTarget: true,
|
||||
initializeDatabases: options.initializeDatabases,
|
||||
mutateTarget: options.mutateTarget,
|
||||
mutateDecisions: options.mutateDecisions,
|
||||
});
|
||||
assert.equal(state.application.outcome, 'adapter_and_manual_required');
|
||||
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) => {
|
||||
const state = await plannedSecretConfigDecisionFixture(t, {
|
||||
suffix: 'decision-terminal',
|
||||
|
||||
Reference in New Issue
Block a user