feat(ql3): bind service lineage to legacy data receipt

This commit is contained in:
whyour
2026-08-21 13:27:40 +08:00
parent f1fc316be1
commit 07cb5ea0a8
9 changed files with 473 additions and 29 deletions
@@ -6,6 +6,7 @@ import {
readPrivateLocalCommandFile, readPrivateLocalCommandFile,
readPrivateLocalJsonFile, readPrivateLocalJsonFile,
} from '@qinglong/local-command-file'; } from '@qinglong/local-command-file';
import { normalizeLocalDataDirectoryApplicationCommit } from '@qinglong/local-sqlite/data-directory-application-commit';
import { import {
advanceLocalCutoverInstanceHead, advanceLocalCutoverInstanceHead,
@@ -283,6 +284,8 @@ function adoptedPaths(
command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>, command: Readonly<LocalServiceManagerLegacyRollbackPrepareCommand>,
expectedApplicationDigest: string, expectedApplicationDigest: string,
expectedCommitmentDigest: string, expectedCommitmentDigest: string,
expectedLegacyDataApplicationCommitDigest: string | null | undefined,
expectedLegacyDataApplicationReceiptDigest: string | null | undefined,
uid: number, uid: number,
gid: number, gid: number,
): Readonly<AdoptedPaths> { ): Readonly<AdoptedPaths> {
@@ -302,6 +305,17 @@ function adoptedPaths(
); );
const storage = object(application.storage, 'adopted storage'); const storage = object(application.storage, 'adopted storage');
const cutover = object(application.cutover, 'adopted cutover'); 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( const commitmentPath = safeAbsolutePath(
cutover.commitmentPath, cutover.commitmentPath,
'commitmentPath', 'commitmentPath',
@@ -319,7 +333,8 @@ function adoptedPaths(
); );
const { commitmentDigest, ...commitmentPayload } = commitment; const { commitmentDigest, ...commitmentPayload } = commitment;
if ( 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.profile !== command.request.profile ||
application.instanceId !== command.request.instanceId || application.instanceId !== command.request.instanceId ||
storage.mode !== 'adopted' || storage.mode !== 'adopted' ||
@@ -340,6 +355,44 @@ function adoptedPaths(
) { ) {
configurationError('adopted application rollback binding drifted'); 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({ return Object.freeze({
activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'), activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'),
legacySourcePath: safeAbsolutePath(storage.sourcePath, 'legacySourcePath'), legacySourcePath: safeAbsolutePath(storage.sourcePath, 'legacySourcePath'),
@@ -571,6 +624,8 @@ export function prepareLocalServiceManagerLegacyRollback(
command, command,
stopped.evidence.applicationConfigDigest, stopped.evidence.applicationConfigDigest,
stopped.evidence.commitmentDigest, stopped.evidence.commitmentDigest,
stopped.evidence.legacyDataApplicationCommitDigest,
stopped.evidence.legacyDataApplicationReceiptDigest,
identity.uid, identity.uid,
identity.gid, identity.gid,
); );
@@ -7,6 +7,7 @@ import {
readPrivateLocalCommandFile, readPrivateLocalCommandFile,
readPrivateLocalJsonFile, readPrivateLocalJsonFile,
} from '@qinglong/local-command-file'; } from '@qinglong/local-command-file';
import { normalizeLocalDataDirectoryApplicationCommit } from '@qinglong/local-sqlite/data-directory-application-commit';
import { currentIdentity } from '../foundation/contract'; import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error'; import { LocalDeploymentConfigurationError } from '../foundation/error';
@@ -96,6 +97,17 @@ interface AdoptedBinding {
readonly recoveryPath: string; readonly recoveryPath: string;
readonly manifestPath: string; readonly manifestPath: string;
readonly applicationConfigDigest: 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 { function configurationError(message: string, cause?: unknown): never {
@@ -249,6 +261,17 @@ function adoptedBinding(
); );
const storage = object(config.storage, 'adopted storage'); const storage = object(config.storage, 'adopted storage');
const cutover = object(config.cutover, 'adopted cutover'); 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( const expectedCommitmentPath = path.join(
intent.deployment.root, intent.deployment.root,
'service', 'service',
@@ -257,7 +280,8 @@ function adoptedBinding(
'0002-legacy-stopped.json', '0002-legacy-stopped.json',
); );
if ( 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.profile !== intent.profile ||
config.instanceId !== intent.instanceId || config.instanceId !== intent.instanceId ||
storage.mode !== 'adopted' || storage.mode !== 'adopted' ||
@@ -265,7 +289,12 @@ function adoptedBinding(
intent.lineage.expectedActivationDigest || intent.lineage.expectedActivationDigest ||
cutover.cutoverId !== intent.lineage.cutoverId || cutover.cutoverId !== intent.lineage.cutoverId ||
typeof cutover.expectedCommitmentDigest !== 'string' || 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'); configurationError('adopted application configuration drifted');
} }
@@ -280,6 +309,19 @@ function adoptedBinding(
recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'), recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'),
manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'), manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'),
applicationConfigDigest: intent.deployment.applicationConfigSha256, 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) { if (binding.commitmentPath !== expectedCommitmentPath) {
configurationError('adopted application material binding drifted'); configurationError('adopted application material binding drifted');
@@ -381,7 +423,7 @@ function verifyAdoptedEvidence(
intent: Readonly<LocalServiceManagerIntent>, intent: Readonly<LocalServiceManagerIntent>,
binding: Readonly<AdoptedBinding>, binding: Readonly<AdoptedBinding>,
uid: number, uid: number,
): string { ): Readonly<VerifiedAdoptedEvidence> {
const activation = object( const activation = object(
readPrivateLocalCommandFile(binding.activationPath), readPrivateLocalCommandFile(binding.activationPath),
'activation', 'activation',
@@ -428,6 +470,31 @@ function verifyAdoptedEvidence(
) { ) {
configurationError('adopted activation or commitment drifted'); 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( const target = privateFileIdentity(
binding.targetPath, binding.targetPath,
uid, uid,
@@ -458,14 +525,18 @@ function verifyAdoptedEvidence(
) { ) {
configurationError('adopted data evidence drifted'); configurationError('adopted data evidence drifted');
} }
return cutoverDigest({ return Object.freeze({
target: target.digest, targetDataIdentityDigest: cutoverDigest({
source: source.digest, target: target.digest,
recovery: recovery.digest, source: source.digest,
recoverySha256, recovery: recovery.digest,
manifest: manifestIdentity.digest, recoverySha256,
manifestDigest, manifest: manifestIdentity.digest,
sourceSha256, manifestDigest,
sourceSha256,
}),
legacyDataApplicationCommitDigest,
legacyDataApplicationReceiptDigest,
}); });
} }
@@ -721,11 +792,7 @@ export async function consumeLocalServiceManagerCutoverOutcome(
} }
const state = desiredState(intent, outcome); const state = desiredState(intent, outcome);
const binding = adoptedBinding(intent); const binding = adoptedBinding(intent);
const targetDataIdentityDigest = verifyAdoptedEvidence( const adoptedEvidence = verifyAdoptedEvidence(intent, binding, identity.uid);
intent,
binding,
identity.uid,
);
let startupReceiptDigest: string | null = null; let startupReceiptDigest: string | null = null;
let shutdownReceiptDigest: string | null = null; let shutdownReceiptDigest: string | null = null;
let processIdentityDigest: string | null = null; let processIdentityDigest: string | null = null;
@@ -806,7 +873,11 @@ export async function consumeLocalServiceManagerCutoverOutcome(
applicationConfigDigest: binding.applicationConfigDigest, applicationConfigDigest: binding.applicationConfigDigest,
activationDigest: binding.activationDigest, activationDigest: binding.activationDigest,
commitmentDigest: binding.commitmentDigest, commitmentDigest: binding.commitmentDigest,
targetDataIdentityDigest, targetDataIdentityDigest: adoptedEvidence.targetDataIdentityDigest,
legacyDataApplicationCommitDigest:
adoptedEvidence.legacyDataApplicationCommitDigest,
legacyDataApplicationReceiptDigest:
adoptedEvidence.legacyDataApplicationReceiptDigest,
startupReceiptDigest, startupReceiptDigest,
shutdownReceiptDigest, shutdownReceiptDigest,
processIdentityDigest, processIdentityDigest,
@@ -27,6 +27,8 @@ export interface LocalServiceManagerCutoverEvidence {
readonly activationDigest: string; readonly activationDigest: string;
readonly commitmentDigest: string; readonly commitmentDigest: string;
readonly targetDataIdentityDigest: string; readonly targetDataIdentityDigest: string;
readonly legacyDataApplicationCommitDigest?: string | null;
readonly legacyDataApplicationReceiptDigest?: string | null;
readonly startupReceiptDigest: string | null; readonly startupReceiptDigest: string | null;
readonly shutdownReceiptDigest: string | null; readonly shutdownReceiptDigest: string | null;
readonly processIdentityDigest: string | null; readonly processIdentityDigest: string | null;
@@ -35,7 +37,7 @@ export interface LocalServiceManagerCutoverEvidence {
export interface LocalServiceManagerCutoverRecord { export interface LocalServiceManagerCutoverRecord {
readonly schema: typeof SCHEMA; readonly schema: typeof SCHEMA;
readonly schemaVersion: 1; readonly schemaVersion: 1 | 2;
readonly actionId: string; readonly actionId: string;
readonly action: LocalServiceManagerAction; readonly action: LocalServiceManagerAction;
readonly state: LocalServiceManagerCutoverState; readonly state: LocalServiceManagerCutoverState;
@@ -135,9 +137,25 @@ export function localServiceManagerCutoverRecord(
if (intent.lineage.mode !== 'adopted') { if (intent.lineage.mode !== 'adopted') {
configurationError('fresh service intent has no cutover lineage'); 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({ const payload = Object.freeze({
schema: SCHEMA, schema: SCHEMA,
schemaVersion: 1 as const, schemaVersion: hasCommitDigest ? (2 as const) : (1 as const),
actionId: intent.actionId, actionId: intent.actionId,
action: intent.action, action: intent.action,
state, state,
@@ -190,6 +208,12 @@ export function normalizeLocalServiceManagerCutoverRecord(
'commitmentDigest', 'commitmentDigest',
'managerObservationDigest', 'managerObservationDigest',
'managerOutcomeDigest', 'managerOutcomeDigest',
...(record.schemaVersion === 2
? [
'legacyDataApplicationCommitDigest',
'legacyDataApplicationReceiptDigest',
]
: []),
'manualReason', 'manualReason',
'processIdentityDigest', 'processIdentityDigest',
'shutdownReceiptDigest', 'shutdownReceiptDigest',
@@ -204,7 +228,7 @@ export function normalizeLocalServiceManagerCutoverRecord(
const { recordDigest, ...payload } = record; const { recordDigest, ...payload } = record;
if ( if (
record.schema !== SCHEMA || record.schema !== SCHEMA ||
record.schemaVersion !== 1 || (record.schemaVersion !== 1 && record.schemaVersion !== 2) ||
typeof record.actionId !== 'string' || typeof record.actionId !== 'string' ||
(record.action !== 'install-enable-start' && (record.action !== 'install-enable-start' &&
record.action !== 'start' && record.action !== 'start' &&
@@ -240,6 +264,11 @@ export function normalizeLocalServiceManagerCutoverRecord(
!DIGEST_PATTERN.test(evidence.commitmentDigest) || !DIGEST_PATTERN.test(evidence.commitmentDigest) ||
typeof evidence.targetDataIdentityDigest !== 'string' || typeof evidence.targetDataIdentityDigest !== 'string' ||
!DIGEST_PATTERN.test(evidence.targetDataIdentityDigest) || !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.startupReceiptDigest) ||
!nullableDigest(evidence.shutdownReceiptDigest) || !nullableDigest(evidence.shutdownReceiptDigest) ||
!nullableDigest(evidence.processIdentityDigest) || !nullableDigest(evidence.processIdentityDigest) ||
@@ -6,6 +6,7 @@ import {
MAX_PRIVATE_LOCAL_JSON_FILE_BYTES, MAX_PRIVATE_LOCAL_JSON_FILE_BYTES,
readPrivateLocalJsonFile, readPrivateLocalJsonFile,
} from '@qinglong/local-command-file'; } from '@qinglong/local-command-file';
import { normalizeLocalDataDirectoryApplicationCommit } from '@qinglong/local-sqlite/data-directory-application-commit';
import { currentIdentity } from '../foundation/contract'; import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error'; import { LocalDeploymentConfigurationError } from '../foundation/error';
@@ -249,6 +250,11 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{
targetDatabasePath: string; targetDatabasePath: string;
recoveryPath: string; recoveryPath: string;
manifestPath: string; manifestPath: string;
legacyDataApplication?: Readonly<{
commitPath: string;
expectedCommitDigest: string;
expectedReceiptDigest: string;
}>;
}>; }>;
}> { }> {
let value: unknown; let value: unknown;
@@ -262,7 +268,8 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{
const application = object(value, 'application configuration'); const application = object(value, 'application configuration');
if ( if (
(application.schema !== 'qinglong/local-application-process@v2' && (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') || (application.profile !== 'edge' && application.profile !== 'standalone') ||
typeof application.instanceId !== 'string' typeof application.instanceId !== 'string'
) { ) {
@@ -280,13 +287,32 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{
}); });
} }
const cutover = object(application.cutover, 'application cutover'); 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 ( if (
storage.mode !== 'adopted' || storage.mode !== 'adopted' ||
typeof cutover.cutoverId !== 'string' || typeof cutover.cutoverId !== 'string' ||
typeof storage.expectedActivationDigest !== 'string' || typeof storage.expectedActivationDigest !== 'string' ||
!DIGEST_PATTERN.test(storage.expectedActivationDigest) || !DIGEST_PATTERN.test(storage.expectedActivationDigest) ||
typeof cutover.expectedCommitmentDigest !== 'string' || 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'); configurationError('adopted application binding is invalid');
} }
@@ -316,10 +342,55 @@ function parseApplicationIdentity(bytes: Buffer): Readonly<{
), ),
recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'), recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'),
manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'), 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<typeof parseApplicationIdentity>,
): 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( function assertApplicationLineageBinding(
application: ReturnType<typeof parseApplicationIdentity>, application: ReturnType<typeof parseApplicationIdentity>,
intent: Readonly<LocalServiceManagerIntent>, intent: Readonly<LocalServiceManagerIntent>,
@@ -546,6 +617,7 @@ export function prepareLocalServiceManagerIntent(
intentDigest: localServiceManagerIntentDigest(payload), intentDigest: localServiceManagerIntentDigest(payload),
}); });
assertApplicationLineageBinding(application, intent); assertApplicationLineageBinding(application, intent);
verifyApplicationDataCommitment(application);
assertIntentLineageHead(intent, identity.uid); assertIntentLineageHead(intent, identity.uid);
const intentPath = localServiceManagerIntentPath(root, intent.actionId); const intentPath = localServiceManagerIntentPath(root, intent.actionId);
const contents = `${JSON.stringify(intent, null, 2)}\n`; const contents = `${JSON.stringify(intent, null, 2)}\n`;
@@ -700,12 +772,15 @@ export function consumeLocalServiceManagerOutcome(
'service descriptor', 'service descriptor',
); );
try { try {
const application = parseApplicationIdentity(applicationBytes);
if ( if (
sha256(applicationBytes) !== intent.deployment.applicationConfigSha256 || sha256(applicationBytes) !== intent.deployment.applicationConfigSha256 ||
sha256(descriptorBytes) !== intent.descriptor.sha256 sha256(descriptorBytes) !== intent.descriptor.sha256
) { ) {
configurationError('service manager source material drifted'); configurationError('service manager source material drifted');
} }
assertApplicationLineageBinding(application, intent);
verifyApplicationDataCommitment(application);
} finally { } finally {
applicationBytes.fill(0); applicationBytes.fill(0);
descriptorBytes.fill(0); descriptorBytes.fill(0);
@@ -36,6 +36,9 @@ const {
const { const {
cutoverDigest, cutoverDigest,
} = require('../dist/deployment/cutover/targetEvidence.js'); } = require('../dist/deployment/cutover/targetEvidence.js');
const {
createLocalDataDirectoryApplicationCommit,
} = require('@qinglong/local-sqlite/data-directory-application-commit');
function sha256(value) { function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex'); return crypto.createHash('sha256').update(value).digest('hex');
@@ -124,9 +127,29 @@ function fixture(t) {
...commitmentPayload, ...commitmentPayload,
commitmentDigest, 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'); const applicationPath = path.join(root, 'local-application.json');
writePrivate(applicationPath, { writePrivate(applicationPath, {
schema: 'qinglong/local-application-process@v3', schema: 'qinglong/local-application-process@v4',
instanceId: 'edge-router-1', instanceId: 'edge-router-1',
profile: 'edge', profile: 'edge',
storage: { storage: {
@@ -146,6 +169,11 @@ function fixture(t) {
commitmentPath, commitmentPath,
expectedCommitmentDigest: commitmentDigest, expectedCommitmentDigest: commitmentDigest,
}, },
legacyDataApplication: {
commitPath: dataCommitPath,
expectedCommitDigest: dataCommit.commitDigest,
expectedReceiptDigest: dataCommit.receiptDigest,
},
}); });
writePrivate( writePrivate(
path.join(service, 'qinglong3.service'), path.join(service, 'qinglong3.service'),
@@ -178,6 +206,8 @@ function fixture(t) {
activationDigest, activationDigest,
commitmentDigest, commitmentDigest,
commitmentPath, commitmentPath,
dataCommit,
dataCommitPath,
applicationPath, applicationPath,
sourcePath, sourcePath,
targetPath, 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')); const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
assert.equal(record.evidence.startupReceiptDigest, receiptDigest); assert.equal(record.evidence.startupReceiptDigest, receiptDigest);
assert.match(record.evidence.processIdentityDigest, /^[0-9a-f]{64}$/); 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( assert.equal(
( (
await consumeLocalServiceManagerCutoverOutcome(command, { await consumeLocalServiceManagerCutoverOutcome(command, {
@@ -930,6 +969,40 @@ test('rejects legacy source content drift before committing service active', asy
assert.equal(head.state, 'legacy_stopped'); 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) => { test('terminalizes a manager PID replaced before Owner receipt verification', async (t) => {
const state = fixture(t); const state = fixture(t);
const prepared = prepare( const prepared = prepare(
@@ -19,6 +19,9 @@ const {
advanceLocalCutoverInstanceHead, advanceLocalCutoverInstanceHead,
claimLocalCutoverInstance, claimLocalCutoverInstance,
} = require('../dist/deployment/cutover/instanceLineage.js'); } = require('../dist/deployment/cutover/instanceLineage.js');
const {
createLocalDataDirectoryApplicationCommit,
} = require('@qinglong/local-sqlite/data-directory-application-commit');
const roots = []; 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', () => { test('does not allow fresh service intent to bypass an existing cutover head', () => {
const { root } = fixture(); const { root } = fixture();
adoptedHead(root); adoptedHead(root);
+9
View File
@@ -1781,6 +1781,15 @@ function auditSourceImports(root, packagePath, findings) {
'src/lifecycle/data-directory-adoption/application/cleanup.ts' && 'src/lifecycle/data-directory-adoption/application/cleanup.ts' &&
specifier === '@qinglong/local-sqlite/data-directory-adoption' 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) === path.relative(packageDirectory, filePath) ===
'src/plugin-package/pluginPackageCommand.ts' && 'src/plugin-package/pluginPackageCommand.ts' &&
@@ -813,7 +813,12 @@ function execute(root, controllerRoot, managerOptions, prepared, uid, gid) {
return { commandPath, result, consumed }; 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; let pid = kind === 'systemd' ? outcome.observation.mainPid : 0;
if (kind === 'openrc') { if (kind === 'openrc') {
for (const entry of fs.readdirSync('/proc')) { for (const entry of fs.readdirSync('/proc')) {
@@ -822,7 +827,12 @@ function serviceProcessUid(kind, outcome) {
const command = fs const command = fs
.readFileSync(`/proc/${entry}/cmdline`, 'utf8') .readFileSync(`/proc/${entry}/cmdline`, 'utf8')
.split('\0'); .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); pid = Number(entry);
break; break;
} }
@@ -1128,9 +1138,17 @@ async function main(argv) {
) { ) {
fail('adopted legacy rollback did not converge to legacy_running'); fail('adopted legacy rollback did not converge to legacy_running');
} }
const legacyUid = serviceProcessUid(kind, { const legacyUid = serviceProcessUid(
observation: adoptedLegacyStarted.outcome.legacyObservation, kind,
}); {
observation: adoptedLegacyStarted.outcome.legacyObservation,
},
fs.realpathSync(process.execPath),
[
'/workspace/scripts/ql3-service-manager-legacy-live-service.cjs',
String(legacyHttpPort),
],
);
if (legacyUid !== uid) { if (legacyUid !== uid) {
fail(`legacy service process UID drifted: ${legacyUid} != ${uid}`); fail(`legacy service process UID drifted: ${legacyUid} != ${uid}`);
} }
@@ -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) => { test('local AI application imports only the reviewed dynamic composition subpaths', (t) => {
const root = fs.mkdtempSync( const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-local-ai-application-boundary-'), path.join(os.tmpdir(), 'ql3-local-ai-application-boundary-'),