diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/legacy-rollback/preparation.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/legacy-rollback/preparation.ts index d1df62d8..418f7195 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/legacy-rollback/preparation.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/legacy-rollback/preparation.ts @@ -6,6 +6,7 @@ import { readPrivateLocalCommandFile, readPrivateLocalJsonFile, } from '@qinglong/local-command-file'; +import { normalizeLocalDataDirectoryApplicationCommit } from '@qinglong/local-sqlite/data-directory-application-commit'; import { advanceLocalCutoverInstanceHead, @@ -283,6 +284,8 @@ function adoptedPaths( command: Readonly, expectedApplicationDigest: string, expectedCommitmentDigest: string, + expectedLegacyDataApplicationCommitDigest: string | null | undefined, + expectedLegacyDataApplicationReceiptDigest: string | null | undefined, uid: number, gid: number, ): Readonly { @@ -302,6 +305,17 @@ function adoptedPaths( ); const storage = object(application.storage, 'adopted storage'); const cutover = object(application.cutover, 'adopted cutover'); + const legacyDataApplication = + application.schema === 'qinglong/local-application-process@v4' + ? object(application.legacyDataApplication, 'legacy data application') + : undefined; + if (legacyDataApplication !== undefined) { + exact( + legacyDataApplication, + ['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'], + 'legacy data application', + ); + } const commitmentPath = safeAbsolutePath( cutover.commitmentPath, 'commitmentPath', @@ -319,7 +333,8 @@ function adoptedPaths( ); const { commitmentDigest, ...commitmentPayload } = commitment; if ( - application.schema !== 'qinglong/local-application-process@v3' || + (application.schema !== 'qinglong/local-application-process@v3' && + application.schema !== 'qinglong/local-application-process@v4') || application.profile !== command.request.profile || application.instanceId !== command.request.instanceId || storage.mode !== 'adopted' || @@ -340,6 +355,44 @@ function adoptedPaths( ) { configurationError('adopted application rollback binding drifted'); } + if (legacyDataApplication === undefined) { + if ( + expectedLegacyDataApplicationCommitDigest != null || + expectedLegacyDataApplicationReceiptDigest != null + ) { + configurationError('legacy data application rollback lineage drifted'); + } + } else { + try { + const dataCommit = normalizeLocalDataDirectoryApplicationCommit( + readPrivateLocalCommandFile( + safeAbsolutePath( + legacyDataApplication.commitPath, + 'legacyDataApplication.commitPath', + ), + ), + ); + if ( + typeof expectedLegacyDataApplicationCommitDigest !== 'string' || + typeof expectedLegacyDataApplicationReceiptDigest !== 'string' || + dataCommit.profile !== command.request.profile || + dataCommit.commitDigest !== + legacyDataApplication.expectedCommitDigest || + dataCommit.receiptDigest !== + legacyDataApplication.expectedReceiptDigest || + dataCommit.commitDigest !== expectedLegacyDataApplicationCommitDigest || + dataCommit.receiptDigest !== expectedLegacyDataApplicationReceiptDigest + ) { + configurationError('legacy data application rollback lineage drifted'); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError( + 'legacy data application rollback lineage is invalid', + error, + ); + } + } return Object.freeze({ activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'), legacySourcePath: safeAbsolutePath(storage.sourcePath, 'legacySourcePath'), @@ -571,6 +624,8 @@ export function prepareLocalServiceManagerLegacyRollback( command, stopped.evidence.applicationConfigDigest, stopped.evidence.commitmentDigest, + stopped.evidence.legacyDataApplicationCommitDigest, + stopped.evidence.legacyDataApplicationReceiptDigest, identity.uid, identity.gid, ); diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts index 5120d3c8..9ada8510 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts @@ -7,6 +7,7 @@ import { readPrivateLocalCommandFile, readPrivateLocalJsonFile, } from '@qinglong/local-command-file'; +import { normalizeLocalDataDirectoryApplicationCommit } from '@qinglong/local-sqlite/data-directory-application-commit'; import { currentIdentity } from '../foundation/contract'; import { LocalDeploymentConfigurationError } from '../foundation/error'; @@ -96,6 +97,17 @@ interface AdoptedBinding { readonly recoveryPath: string; readonly manifestPath: string; readonly applicationConfigDigest: string; + readonly legacyDataApplication?: Readonly<{ + commitPath: string; + commitDigest: string; + receiptDigest: string; + }>; +} + +interface VerifiedAdoptedEvidence { + readonly targetDataIdentityDigest: string; + readonly legacyDataApplicationCommitDigest: string | null; + readonly legacyDataApplicationReceiptDigest: string | null; } function configurationError(message: string, cause?: unknown): never { @@ -249,6 +261,17 @@ function adoptedBinding( ); const storage = object(config.storage, 'adopted storage'); const cutover = object(config.cutover, 'adopted cutover'); + const legacyDataApplication = + config.schema === 'qinglong/local-application-process@v4' + ? object(config.legacyDataApplication, 'legacy data application binding') + : undefined; + if (legacyDataApplication !== undefined) { + exact( + legacyDataApplication, + ['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'], + 'legacy data application binding', + ); + } const expectedCommitmentPath = path.join( intent.deployment.root, 'service', @@ -257,7 +280,8 @@ function adoptedBinding( '0002-legacy-stopped.json', ); if ( - config.schema !== 'qinglong/local-application-process@v3' || + (config.schema !== 'qinglong/local-application-process@v3' && + config.schema !== 'qinglong/local-application-process@v4') || config.profile !== intent.profile || config.instanceId !== intent.instanceId || storage.mode !== 'adopted' || @@ -265,7 +289,12 @@ function adoptedBinding( intent.lineage.expectedActivationDigest || cutover.cutoverId !== intent.lineage.cutoverId || typeof cutover.expectedCommitmentDigest !== 'string' || - !DIGEST_PATTERN.test(cutover.expectedCommitmentDigest) + !DIGEST_PATTERN.test(cutover.expectedCommitmentDigest) || + (legacyDataApplication !== undefined && + (typeof legacyDataApplication.expectedCommitDigest !== 'string' || + !DIGEST_PATTERN.test(legacyDataApplication.expectedCommitDigest) || + typeof legacyDataApplication.expectedReceiptDigest !== 'string' || + !DIGEST_PATTERN.test(legacyDataApplication.expectedReceiptDigest))) ) { configurationError('adopted application configuration drifted'); } @@ -280,6 +309,19 @@ function adoptedBinding( recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'), manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'), applicationConfigDigest: intent.deployment.applicationConfigSha256, + ...(legacyDataApplication === undefined + ? {} + : { + legacyDataApplication: Object.freeze({ + commitPath: safeAbsolutePath( + legacyDataApplication.commitPath, + 'legacyDataApplication.commitPath', + ), + commitDigest: legacyDataApplication.expectedCommitDigest as string, + receiptDigest: + legacyDataApplication.expectedReceiptDigest as string, + }), + }), }); if (binding.commitmentPath !== expectedCommitmentPath) { configurationError('adopted application material binding drifted'); @@ -381,7 +423,7 @@ function verifyAdoptedEvidence( intent: Readonly, binding: Readonly, uid: number, -): string { +): Readonly { const activation = object( readPrivateLocalCommandFile(binding.activationPath), 'activation', @@ -428,6 +470,31 @@ function verifyAdoptedEvidence( ) { configurationError('adopted activation or commitment drifted'); } + let legacyDataApplicationCommitDigest: string | null = null; + let legacyDataApplicationReceiptDigest: string | null = null; + if (binding.legacyDataApplication !== undefined) { + try { + const dataCommit = normalizeLocalDataDirectoryApplicationCommit( + readPrivateLocalCommandFile(binding.legacyDataApplication.commitPath), + ); + if ( + dataCommit.profile !== intent.profile || + dataCommit.commitDigest !== + binding.legacyDataApplication.commitDigest || + dataCommit.receiptDigest !== binding.legacyDataApplication.receiptDigest + ) { + configurationError('legacy data application commitment drifted'); + } + legacyDataApplicationCommitDigest = dataCommit.commitDigest; + legacyDataApplicationReceiptDigest = dataCommit.receiptDigest; + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError( + 'legacy data application commitment is invalid', + error, + ); + } + } const target = privateFileIdentity( binding.targetPath, uid, @@ -458,14 +525,18 @@ function verifyAdoptedEvidence( ) { configurationError('adopted data evidence drifted'); } - return cutoverDigest({ - target: target.digest, - source: source.digest, - recovery: recovery.digest, - recoverySha256, - manifest: manifestIdentity.digest, - manifestDigest, - sourceSha256, + return Object.freeze({ + targetDataIdentityDigest: cutoverDigest({ + target: target.digest, + source: source.digest, + recovery: recovery.digest, + recoverySha256, + manifest: manifestIdentity.digest, + manifestDigest, + sourceSha256, + }), + legacyDataApplicationCommitDigest, + legacyDataApplicationReceiptDigest, }); } @@ -721,11 +792,7 @@ export async function consumeLocalServiceManagerCutoverOutcome( } const state = desiredState(intent, outcome); const binding = adoptedBinding(intent); - const targetDataIdentityDigest = verifyAdoptedEvidence( - intent, - binding, - identity.uid, - ); + const adoptedEvidence = verifyAdoptedEvidence(intent, binding, identity.uid); let startupReceiptDigest: string | null = null; let shutdownReceiptDigest: string | null = null; let processIdentityDigest: string | null = null; @@ -806,7 +873,11 @@ export async function consumeLocalServiceManagerCutoverOutcome( applicationConfigDigest: binding.applicationConfigDigest, activationDigest: binding.activationDigest, commitmentDigest: binding.commitmentDigest, - targetDataIdentityDigest, + targetDataIdentityDigest: adoptedEvidence.targetDataIdentityDigest, + legacyDataApplicationCommitDigest: + adoptedEvidence.legacyDataApplicationCommitDigest, + legacyDataApplicationReceiptDigest: + adoptedEvidence.legacyDataApplicationReceiptDigest, startupReceiptDigest, shutdownReceiptDigest, processIdentityDigest, diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverJournal.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverJournal.ts index 1147085e..e53ecc7b 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverJournal.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverJournal.ts @@ -27,6 +27,8 @@ export interface LocalServiceManagerCutoverEvidence { readonly activationDigest: string; readonly commitmentDigest: string; readonly targetDataIdentityDigest: string; + readonly legacyDataApplicationCommitDigest?: string | null; + readonly legacyDataApplicationReceiptDigest?: string | null; readonly startupReceiptDigest: string | null; readonly shutdownReceiptDigest: string | null; readonly processIdentityDigest: string | null; @@ -35,7 +37,7 @@ export interface LocalServiceManagerCutoverEvidence { export interface LocalServiceManagerCutoverRecord { readonly schema: typeof SCHEMA; - readonly schemaVersion: 1; + readonly schemaVersion: 1 | 2; readonly actionId: string; readonly action: LocalServiceManagerAction; readonly state: LocalServiceManagerCutoverState; @@ -135,9 +137,25 @@ export function localServiceManagerCutoverRecord( if (intent.lineage.mode !== 'adopted') { configurationError('fresh service intent has no cutover lineage'); } + const hasCommitDigest = Object.hasOwn( + evidence, + 'legacyDataApplicationCommitDigest', + ); + const hasReceiptDigest = Object.hasOwn( + evidence, + 'legacyDataApplicationReceiptDigest', + ); + if ( + hasCommitDigest !== hasReceiptDigest || + (hasCommitDigest && + (evidence.legacyDataApplicationCommitDigest === null) !== + (evidence.legacyDataApplicationReceiptDigest === null)) + ) { + configurationError('legacy data application evidence is incomplete'); + } const payload = Object.freeze({ schema: SCHEMA, - schemaVersion: 1 as const, + schemaVersion: hasCommitDigest ? (2 as const) : (1 as const), actionId: intent.actionId, action: intent.action, state, @@ -190,6 +208,12 @@ export function normalizeLocalServiceManagerCutoverRecord( 'commitmentDigest', 'managerObservationDigest', 'managerOutcomeDigest', + ...(record.schemaVersion === 2 + ? [ + 'legacyDataApplicationCommitDigest', + 'legacyDataApplicationReceiptDigest', + ] + : []), 'manualReason', 'processIdentityDigest', 'shutdownReceiptDigest', @@ -204,7 +228,7 @@ export function normalizeLocalServiceManagerCutoverRecord( const { recordDigest, ...payload } = record; if ( record.schema !== SCHEMA || - record.schemaVersion !== 1 || + (record.schemaVersion !== 1 && record.schemaVersion !== 2) || typeof record.actionId !== 'string' || (record.action !== 'install-enable-start' && record.action !== 'start' && @@ -240,6 +264,11 @@ export function normalizeLocalServiceManagerCutoverRecord( !DIGEST_PATTERN.test(evidence.commitmentDigest) || typeof evidence.targetDataIdentityDigest !== 'string' || !DIGEST_PATTERN.test(evidence.targetDataIdentityDigest) || + (record.schemaVersion === 2 && + (!nullableDigest(evidence.legacyDataApplicationCommitDigest) || + !nullableDigest(evidence.legacyDataApplicationReceiptDigest) || + (evidence.legacyDataApplicationCommitDigest === null) !== + (evidence.legacyDataApplicationReceiptDigest === null))) || !nullableDigest(evidence.startupReceiptDigest) || !nullableDigest(evidence.shutdownReceiptDigest) || !nullableDigest(evidence.processIdentityDigest) || diff --git a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceManagerIntent.ts b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceManagerIntent.ts index e1fc5203..a28096c6 100644 --- a/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceManagerIntent.ts +++ b/packages/ql3-local-owner-cli/src/deployment/service-manager/serviceManagerIntent.ts @@ -6,6 +6,7 @@ import { MAX_PRIVATE_LOCAL_JSON_FILE_BYTES, readPrivateLocalJsonFile, } from '@qinglong/local-command-file'; +import { normalizeLocalDataDirectoryApplicationCommit } from '@qinglong/local-sqlite/data-directory-application-commit'; import { currentIdentity } from '../foundation/contract'; import { LocalDeploymentConfigurationError } from '../foundation/error'; @@ -249,6 +250,11 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{ targetDatabasePath: string; recoveryPath: string; manifestPath: string; + legacyDataApplication?: Readonly<{ + commitPath: string; + expectedCommitDigest: string; + expectedReceiptDigest: string; + }>; }>; }> { let value: unknown; @@ -262,7 +268,8 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{ const application = object(value, 'application configuration'); if ( (application.schema !== 'qinglong/local-application-process@v2' && - application.schema !== 'qinglong/local-application-process@v3') || + application.schema !== 'qinglong/local-application-process@v3' && + application.schema !== 'qinglong/local-application-process@v4') || (application.profile !== 'edge' && application.profile !== 'standalone') || typeof application.instanceId !== 'string' ) { @@ -280,13 +287,32 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{ }); } const cutover = object(application.cutover, 'application cutover'); + const legacyDataApplication = + application.schema === 'qinglong/local-application-process@v4' + ? object( + application.legacyDataApplication, + 'legacy data application binding', + ) + : undefined; + if (legacyDataApplication !== undefined) { + exact( + legacyDataApplication, + ['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'], + 'legacy data application binding', + ); + } if ( storage.mode !== 'adopted' || typeof cutover.cutoverId !== 'string' || typeof storage.expectedActivationDigest !== 'string' || !DIGEST_PATTERN.test(storage.expectedActivationDigest) || typeof cutover.expectedCommitmentDigest !== 'string' || - !DIGEST_PATTERN.test(cutover.expectedCommitmentDigest) + !DIGEST_PATTERN.test(cutover.expectedCommitmentDigest) || + (legacyDataApplication !== undefined && + (typeof legacyDataApplication.expectedCommitDigest !== 'string' || + !DIGEST_PATTERN.test(legacyDataApplication.expectedCommitDigest) || + typeof legacyDataApplication.expectedReceiptDigest !== 'string' || + !DIGEST_PATTERN.test(legacyDataApplication.expectedReceiptDigest))) ) { configurationError('adopted application binding is invalid'); } @@ -316,10 +342,55 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{ ), recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'), manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'), + ...(legacyDataApplication === undefined + ? {} + : { + legacyDataApplication: Object.freeze({ + commitPath: safeAbsolutePath( + legacyDataApplication.commitPath, + 'legacyDataApplication.commitPath', + ), + expectedCommitDigest: + legacyDataApplication.expectedCommitDigest as string, + expectedReceiptDigest: + legacyDataApplication.expectedReceiptDigest as string, + }), + }), }), }); } +function verifyApplicationDataCommitment( + application: ReturnType, +): void { + if ( + application.deployment.mode !== 'adopted' || + application.deployment.legacyDataApplication === undefined + ) { + return; + } + try { + const binding = application.deployment.legacyDataApplication; + const commit = normalizeLocalDataDirectoryApplicationCommit( + readPrivateLocalJsonFile(binding.commitPath, { + maxBytes: 64 * 1024, + }), + ); + if ( + commit.profile !== application.profile || + commit.commitDigest !== binding.expectedCommitDigest || + commit.receiptDigest !== binding.expectedReceiptDigest + ) { + configurationError( + 'legacy data application commit does not match the application binding', + ); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError('legacy data application commitment is invalid', error); + } +} + function assertApplicationLineageBinding( application: ReturnType, intent: Readonly, @@ -546,6 +617,7 @@ export function prepareLocalServiceManagerIntent( intentDigest: localServiceManagerIntentDigest(payload), }); assertApplicationLineageBinding(application, intent); + verifyApplicationDataCommitment(application); assertIntentLineageHead(intent, identity.uid); const intentPath = localServiceManagerIntentPath(root, intent.actionId); const contents = `${JSON.stringify(intent, null, 2)}\n`; @@ -700,12 +772,15 @@ export function consumeLocalServiceManagerOutcome( 'service descriptor', ); try { + const application = parseApplicationIdentity(applicationBytes); if ( sha256(applicationBytes) !== intent.deployment.applicationConfigSha256 || sha256(descriptorBytes) !== intent.descriptor.sha256 ) { configurationError('service manager source material drifted'); } + assertApplicationLineageBinding(application, intent); + verifyApplicationDataCommitment(application); } finally { applicationBytes.fill(0); descriptorBytes.fill(0); diff --git a/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs b/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs index 4c002f86..6f4213c4 100644 --- a/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs +++ b/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs @@ -36,6 +36,9 @@ const { const { cutoverDigest, } = require('../dist/deployment/cutover/targetEvidence.js'); +const { + createLocalDataDirectoryApplicationCommit, +} = require('@qinglong/local-sqlite/data-directory-application-commit'); function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); @@ -124,9 +127,29 @@ function fixture(t) { ...commitmentPayload, commitmentDigest, }); + const dataCommit = createLocalDataDirectoryApplicationCommit({ + mutationId: '00000000-0000-4000-8000-000000000001', + projectId: 'project-edge-router-1', + profile: 'edge', + sourceStageManifestDigest: '9'.repeat(64), + transformationDigest: 'a'.repeat(64), + modelDigest: 'b'.repeat(64), + publicationDigest: 'c'.repeat(64), + receiptDigest: 'd'.repeat(64), + committedAtMs: 1786416000025, + receipt: { + secretCount: 2, + environmentSecretCount: 1, + sshSecretCount: 1, + }, + }); + const transformationRoot = path.join(root, 'transformation'); + fs.mkdirSync(transformationRoot, { mode: 0o700 }); + const dataCommitPath = path.join(transformationRoot, 'commit.json'); + writePrivate(dataCommitPath, dataCommit); const applicationPath = path.join(root, 'local-application.json'); writePrivate(applicationPath, { - schema: 'qinglong/local-application-process@v3', + schema: 'qinglong/local-application-process@v4', instanceId: 'edge-router-1', profile: 'edge', storage: { @@ -146,6 +169,11 @@ function fixture(t) { commitmentPath, expectedCommitmentDigest: commitmentDigest, }, + legacyDataApplication: { + commitPath: dataCommitPath, + expectedCommitDigest: dataCommit.commitDigest, + expectedReceiptDigest: dataCommit.receiptDigest, + }, }); writePrivate( path.join(service, 'qinglong3.service'), @@ -178,6 +206,8 @@ function fixture(t) { activationDigest, commitmentDigest, commitmentPath, + dataCommit, + dataCommitPath, applicationPath, sourcePath, targetPath, @@ -554,6 +584,15 @@ test('commits adopted service active evidence and replays from the instance head const record = JSON.parse(fs.readFileSync(recordPath, 'utf8')); assert.equal(record.evidence.startupReceiptDigest, receiptDigest); assert.match(record.evidence.processIdentityDigest, /^[0-9a-f]{64}$/); + assert.equal(record.schemaVersion, 2); + assert.equal( + record.evidence.legacyDataApplicationCommitDigest, + state.dataCommit.commitDigest, + ); + assert.equal( + record.evidence.legacyDataApplicationReceiptDigest, + state.dataCommit.receiptDigest, + ); assert.equal( ( await consumeLocalServiceManagerCutoverOutcome(command, { @@ -930,6 +969,40 @@ test('rejects legacy source content drift before committing service active', asy assert.equal(head.state, 'legacy_stopped'); }); +test('rejects legacy data receipt drift after manager outcome without advancing lineage', async (t) => { + const state = fixture(t); + const prepared = prepare( + state, + 1, + 'install-enable-start', + state.commitmentDigest, + '123e4567-e89b-42d3-a456-426614174040', + ); + publishOutcome( + prepared, + 'install-enable-start', + 'active', + 4823, + 1786416000200, + ); + publishReceipt(state, 4823, '100008'); + const drifted = JSON.parse(fs.readFileSync(state.dataCommitPath, 'utf8')); + drifted.receiptDigest = '0'.repeat(64); + writePrivate(state.dataCommitPath, drifted); + await assert.rejects( + consumeLocalServiceManagerCutoverOutcome(consumeCommand(state, prepared), { + procRoot: state.procRoot, + }), + /legacy data application commitment is invalid/, + ); + const head = readLocalCutoverInstanceHead( + state.root, + 'edge-router-1', + process.getuid(), + ); + assert.equal(head.state, 'legacy_stopped'); +}); + test('terminalizes a manager PID replaced before Owner receipt verification', async (t) => { const state = fixture(t); const prepared = prepare( diff --git a/packages/ql3-local-owner-cli/test/serviceManagerIntent.test.cjs b/packages/ql3-local-owner-cli/test/serviceManagerIntent.test.cjs index 7202610e..abfb9c0c 100644 --- a/packages/ql3-local-owner-cli/test/serviceManagerIntent.test.cjs +++ b/packages/ql3-local-owner-cli/test/serviceManagerIntent.test.cjs @@ -19,6 +19,9 @@ const { advanceLocalCutoverInstanceHead, claimLocalCutoverInstance, } = require('../dist/deployment/cutover/instanceLineage.js'); +const { + createLocalDataDirectoryApplicationCommit, +} = require('@qinglong/local-sqlite/data-directory-application-commit'); const roots = []; @@ -247,6 +250,68 @@ test('binds an adopted first start to the current legacy-stopped instance head', ); }); +test('rejects v4 legacy data receipt drift before publishing a service intent', () => { + const { root } = fixture(); + const { identity, previousRecordDigest } = adoptedHead(root); + adoptedApplication(root, identity, previousRecordDigest); + const applicationPath = path.join(root, 'local-application.json'); + const application = JSON.parse(fs.readFileSync(applicationPath, 'utf8')); + const commit = createLocalDataDirectoryApplicationCommit({ + mutationId: '00000000-0000-4000-8000-000000000001', + projectId: 'project-edge-router-1', + profile: 'edge', + sourceStageManifestDigest: '1'.repeat(64), + transformationDigest: '2'.repeat(64), + modelDigest: '3'.repeat(64), + publicationDigest: '4'.repeat(64), + receiptDigest: '5'.repeat(64), + committedAtMs: 1786416000001, + receipt: { + secretCount: 2, + environmentSecretCount: 1, + sshSecretCount: 1, + }, + }); + const commitPath = path.join(root, 'legacy-data-commit.json'); + fs.writeFileSync(commitPath, `${JSON.stringify(commit)}\n`, { mode: 0o600 }); + fs.writeFileSync( + applicationPath, + `${JSON.stringify({ + ...application, + schema: 'qinglong/local-application-process@v4', + legacyDataApplication: { + commitPath, + expectedCommitDigest: commit.commitDigest, + expectedReceiptDigest: '0'.repeat(64), + }, + })}\n`, + { mode: 0o600 }, + ); + const command = prepareCommand(root); + command.request.lineage = { + mode: 'adopted', + cutoverId: identity.request.cutoverId, + generation: 1, + expectedActivationDigest: identity.request.expectedActivationDigest, + previousRecordDigest, + }; + assert.throws( + () => prepareLocalServiceManagerIntent(command), + /legacy data application commit does not match the application binding/, + ); + assert.equal( + fs.existsSync( + path.join( + root, + 'service', + 'service-manager-intents', + `${command.request.actionId}.json`, + ), + ), + false, + ); +}); + test('does not allow fresh service intent to bypass an existing cutover head', () => { const { root } = fixture(); adoptedHead(root); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 801c42bb..39622eef 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -1781,6 +1781,15 @@ function auditSourceImports(root, packagePath, findings) { 'src/lifecycle/data-directory-adoption/application/cleanup.ts' && specifier === '@qinglong/local-sqlite/data-directory-adoption' ) && + !( + [ + 'src/deployment/service-manager/serviceManagerIntent.ts', + 'src/deployment/service-manager/serviceCutoverConsumer.ts', + 'src/deployment/service-manager/legacy-rollback/preparation.ts', + ].includes(path.relative(packageDirectory, filePath)) && + specifier === + '@qinglong/local-sqlite/data-directory-application-commit' + ) && !( path.relative(packageDirectory, filePath) === 'src/plugin-package/pluginPackageCommand.ts' && diff --git a/scripts/ql3-service-manager-bridge-live-actor.cjs b/scripts/ql3-service-manager-bridge-live-actor.cjs index eb7bd569..e7a3ee4b 100644 --- a/scripts/ql3-service-manager-bridge-live-actor.cjs +++ b/scripts/ql3-service-manager-bridge-live-actor.cjs @@ -813,7 +813,12 @@ function execute(root, controllerRoot, managerOptions, prepared, uid, gid) { return { commandPath, result, consumed }; } -function serviceProcessUid(kind, outcome) { +function serviceProcessUid( + kind, + outcome, + expectedCommand = '/bin/sleep', + expectedArguments = ['300'], +) { let pid = kind === 'systemd' ? outcome.observation.mainPid : 0; if (kind === 'openrc') { for (const entry of fs.readdirSync('/proc')) { @@ -822,7 +827,12 @@ function serviceProcessUid(kind, outcome) { const command = fs .readFileSync(`/proc/${entry}/cmdline`, 'utf8') .split('\0'); - if (command[0] === '/bin/sleep' && command[1] === '300') { + if ( + command[0] === expectedCommand && + expectedArguments.every( + (argument, index) => command[index + 1] === argument, + ) + ) { pid = Number(entry); break; } @@ -1128,9 +1138,17 @@ async function main(argv) { ) { fail('adopted legacy rollback did not converge to legacy_running'); } - const legacyUid = serviceProcessUid(kind, { - observation: adoptedLegacyStarted.outcome.legacyObservation, - }); + const legacyUid = serviceProcessUid( + kind, + { + observation: adoptedLegacyStarted.outcome.legacyObservation, + }, + fs.realpathSync(process.execPath), + [ + '/workspace/scripts/ql3-service-manager-legacy-live-service.cjs', + String(legacyHttpPort), + ], + ); if (legacyUid !== uid) { fail(`legacy service process UID drifted: ${legacyUid} != ${uid}`); } diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index a031c5b0..d5b69743 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -2518,6 +2518,55 @@ test('local application receives only the pure data application commit codec', ( ); }); +test('service-manager Owner consumers receive only the pure data commit codec', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-service-data-commit-codec-boundary-'), + ); + const managerDirectory = path.join( + root, + 'packages/ql3-local-owner-cli/src/deployment/service-manager', + ); + const rollbackDirectory = path.join(managerDirectory, 'legacy-rollback'); + fs.mkdirSync(rollbackDirectory, { recursive: true }); + for (const filePath of [ + path.join(managerDirectory, 'serviceManagerIntent.ts'), + path.join(managerDirectory, 'serviceCutoverConsumer.ts'), + path.join(rollbackDirectory, 'preparation.ts'), + ]) { + fs.writeFileSync( + filePath, + "import { normalize } from '@qinglong/local-sqlite/data-directory-application-commit';", + ); + } + fs.writeFileSync( + path.join(managerDirectory, 'neighbor.ts'), + "import { normalize } from '@qinglong/local-sqlite/data-directory-application-commit';", + ); + fs.appendFileSync( + path.join(managerDirectory, 'serviceCutoverConsumer.ts'), + "\nimport { mutate } from '@qinglong/local-sqlite/data-directory-adoption';", + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const findings = []; + auditSourceImports(root, 'packages/ql3-local-owner-cli', findings); + assert.deepEqual( + findings.map(({ code, file, specifier }) => ({ code, file, specifier })), + [ + { + code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/service-manager/neighbor.ts', + specifier: '@qinglong/local-sqlite/data-directory-application-commit', + }, + { + code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/service-manager/serviceCutoverConsumer.ts', + specifier: '@qinglong/local-sqlite/data-directory-adoption', + }, + ], + ); +}); + test('local AI application imports only the reviewed dynamic composition subpaths', (t) => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'ql3-local-ai-application-boundary-'),