diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/contract.ts index 523679ba..8ae03e70 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/contract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/contract.ts @@ -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; + 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; }>; } @@ -146,13 +161,18 @@ function projectId(value: unknown): string { function normalizeOptions( value: unknown, -): Readonly { + 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; + schemaVersion: 1 | 2; + options: Readonly< + | LocalReconciliationSecretConfigOptions + | LocalReconciliationSecretConfigAutomationOptions + >; request: Record; }> { 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 { + 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 { @@ -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, diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts index bea3a20e..d3bda1ae 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts @@ -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; } +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, + terminal: Readonly, + head: Readonly, + uid: number, +): Promise | 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( + authority: Readonly, + uid: number, + dependencies: LocalReconciliationSecretConfigPlanDependencies, + read: (database: DatabaseSync) => T, +): Promise { + 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, terminal: Readonly, @@ -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, dependencies: LocalReconciliationSecretConfigPlanDependencies, uid: number, + automationTarget?: DatabaseSync, ): Readonly { 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, uid: number): void { +function sealTerminal( + selected: Readonly, + 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> { 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 ( diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 70319e52..e20c7144 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -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', diff --git a/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh b/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh index 1ba7b538..43a147d1 100644 --- a/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh +++ b/scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh @@ -594,7 +594,7 @@ EOF secret_prepared_ms=$((preserved_ms + 1)) cat >"$command_root/secret-config-plan.json" <