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,
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<LocalServiceManagerLegacyRollbackPrepareCommand>,
expectedApplicationDigest: string,
expectedCommitmentDigest: string,
expectedLegacyDataApplicationCommitDigest: string | null | undefined,
expectedLegacyDataApplicationReceiptDigest: string | null | undefined,
uid: number,
gid: number,
): Readonly<AdoptedPaths> {
@@ -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,
);
@@ -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<LocalServiceManagerIntent>,
binding: Readonly<AdoptedBinding>,
uid: number,
): string {
): Readonly<VerifiedAdoptedEvidence> {
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,
@@ -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) ||
@@ -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<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(
application: ReturnType<typeof parseApplicationIdentity>,
intent: Readonly<LocalServiceManagerIntent>,
@@ -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);
@@ -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(
@@ -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);
+9
View File
@@ -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' &&
@@ -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}`);
}
@@ -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-'),