Files
qinglong/packages/ql3-local-owner-cli/test/serviceCutoverConsumer.test.cjs
T

573 lines
16 KiB
JavaScript

const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const {
consumeLocalServiceManagerCutoverOutcome,
} = require('../dist/deployment/service-manager/serviceCutoverConsumer.js');
const {
prepareLocalServiceManagerIntent,
} = require('../dist/deployment/service-manager/serviceManagerIntent.js');
const {
localServiceManagerObservationDigest,
localServiceManagerOutcomeDigest,
} = require('../dist/deployment/service-manager/serviceOutcomeContract.js');
const {
advanceLocalCutoverInstanceHead,
claimLocalCutoverInstance,
readLocalCutoverInstanceHead,
} = require('../dist/deployment/cutover/instanceLineage.js');
const {
cutoverDigest,
} = require('../dist/deployment/cutover/targetEvidence.js');
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function writePrivate(filePath, value) {
fs.writeFileSync(
filePath,
typeof value === 'string' ? value : `${JSON.stringify(value)}\n`,
{ mode: 0o600 },
);
}
function fixture(t) {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-service-cutover-')),
);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.chmodSync(root, 0o700);
const service = path.join(root, 'service');
const cutoverId = 'cutover-edge-router-1';
const journal = path.join(service, 'cutovers', cutoverId);
fs.mkdirSync(journal, { recursive: true, mode: 0o700 });
fs.chmodSync(service, 0o700);
fs.chmodSync(path.join(service, 'cutovers'), 0o700);
fs.chmodSync(journal, 0o700);
const sourcePath = path.join(root, 'legacy.sqlite');
const targetPath = path.join(root, 'target.sqlite');
const recoveryPath = path.join(root, 'recovery.sqlite');
const manifestPath = path.join(root, 'manifest.json');
const activationPath = path.join(root, 'activation.json');
for (const [filePath, contents] of [
[sourcePath, 'legacy\n'],
[targetPath, 'target\n'],
[recoveryPath, 'legacy\n'],
]) {
writePrivate(filePath, contents);
}
const manifestPayload = {
schemaVersion: 1,
kind: 'qinglong3-local-adoption-manifest-fixture',
};
const manifestDigest = cutoverDigest(manifestPayload);
writePrivate(manifestPath, { ...manifestPayload, manifestDigest });
const target = fs.statSync(targetPath, { bigint: true });
const activationPayload = {
schemaVersion: 1,
kind: 'qinglong3-local-sqlite-activation',
state: 'prepared',
profile: 'edge',
createdAtMs: 1786416000000,
adoptionManifestDigest: manifestDigest,
planDigest: '2'.repeat(64),
sourcePathDigest: sha256(sourcePath),
recoverySha256: sha256(fs.readFileSync(recoveryPath)),
targetSha256: sha256(fs.readFileSync(targetPath)),
targetPathDigest: sha256(targetPath),
targetDevice: target.dev.toString(),
targetInode: target.ino.toString(),
};
const activationDigest = cutoverDigest(activationPayload);
writePrivate(activationPath, { ...activationPayload, activationDigest });
const commitmentPayload = {
schemaVersion: 1,
kind: 'qinglong3-local-legacy-silence-commitment',
state: 'legacy_stopped',
cutoverId,
profile: 'edge',
instanceId: 'edge-router-1',
activationDigest,
requestedAtMs: 1786416000010,
observedAtMs: 1786416000020,
previousRecordDigest: '3'.repeat(64),
controller: {
kind: 'docker',
endpointDigest: '4'.repeat(64),
legacyContainerId: '5'.repeat(64),
legacyContainerIdentityDigest: '6'.repeat(64),
legacySourceBindingDigest: '7'.repeat(64),
},
};
const commitmentDigest = cutoverDigest(commitmentPayload);
const commitmentPath = path.join(journal, '0002-legacy-stopped.json');
writePrivate(commitmentPath, {
...commitmentPayload,
commitmentDigest,
});
const applicationPath = path.join(root, 'local-application.json');
writePrivate(applicationPath, {
schema: 'qinglong/local-application-process@v3',
instanceId: 'edge-router-1',
profile: 'edge',
storage: {
mode: 'adopted',
sourcePath,
targetPath,
recoveryPath,
manifestPath,
activationPath,
expectedActivationDigest: activationDigest,
},
runtime: {},
pluginPackages: {},
ai: { deployment: 'excluded' },
cutover: {
cutoverId,
commitmentPath,
expectedCommitmentDigest: commitmentDigest,
},
});
writePrivate(
path.join(service, 'qinglong3.service'),
'[Service]\nExecStart=/usr/bin/node /opt/qinglong3/app.js\n',
);
const identity = {
options: { deploymentRoot: root },
request: {
cutoverId,
profile: 'edge',
instanceId: 'edge-router-1',
expectedActivationDigest: activationDigest,
requestedAtMs: 1786416000030,
},
};
claimLocalCutoverInstance(identity, process.getuid(), '8'.repeat(64));
advanceLocalCutoverInstanceHead(
identity,
process.getuid(),
'legacy_stopped',
0,
commitmentDigest,
);
const procRoot = path.join(root, 'proc');
fs.mkdirSync(procRoot, { mode: 0o700 });
return {
root,
service,
cutoverId,
activationDigest,
commitmentDigest,
applicationPath,
sourcePath,
identity,
procRoot,
};
}
function prepare(state, generation, action, previousRecordDigest, actionId) {
return prepareLocalServiceManagerIntent({
schemaVersion: 1,
operation: 'local.deployment.service-manager.intent.prepare',
options: {
deploymentRoot: state.root,
allowRootService: process.getuid() === 0,
},
request: {
actionId,
action,
serviceKind: 'systemd',
lineage: {
mode: 'adopted',
cutoverId: state.cutoverId,
generation,
expectedActivationDigest: state.activationDigest,
previousRecordDigest,
},
requestedAtMs: 1786416000100 + generation,
},
});
}
function publishOutcome(prepared, action, state, mainPid, completedAtMs) {
const intent = JSON.parse(fs.readFileSync(prepared.intentPath, 'utf8'));
const observationPayload = {
managerKind: 'systemd',
serviceName: 'qinglong3',
fragmentPath: '/etc/systemd/system/qinglong3.service',
loadState: 'loaded',
activeState: state === 'active' ? 'active' : 'inactive',
subState: state === 'active' ? 'running' : 'dead',
enabledState: 'enabled',
mainPid,
observedAtMs: completedAtMs - 1,
};
const observation = {
...observationPayload,
observationDigest: localServiceManagerObservationDigest(observationPayload),
};
const payload = {
schemaVersion: 1,
kind: 'qinglong3-local-service-manager-outcome',
actionId: prepared.actionId,
action,
intentDigest: prepared.intentDigest,
descriptorDigest: intent.descriptor.sha256,
state,
mutationDisposition: 'executed',
manualReason: null,
observation,
completedAtMs,
};
writePrivate(prepared.outcomePath, {
...payload,
outcomeDigest: localServiceManagerOutcomeDigest(payload),
});
}
function publishReceipt(state, processId, startTicks) {
const executable = fs.realpathSync(process.execPath);
const payload = {
schemaVersion: 1,
schema: 'qinglong/local-application-startup-receipt@v1',
instanceId: 'edge-router-1',
profile: 'edge',
aiStatus: 'deployment_excluded',
bootId: '00000000-0000-4000-8000-000000000001',
activeBootAgeMs: 1000,
processId,
processStartTicks: startTicks,
nodeExecutable: executable,
nodeVersion: 'v24.18.0',
};
const digest = crypto
.createHash('sha256')
.update('qinglong.local-application-startup-receipt.v1\0', 'utf8')
.update(JSON.stringify(payload), 'utf8')
.digest('hex');
writePrivate(`${state.applicationPath}.active.json`, {
...payload,
sha256: digest,
});
const processRoot = path.join(state.procRoot, String(processId));
fs.mkdirSync(processRoot, { mode: 0o700 });
const fields = ['S', ...Array(18).fill('0'), startTicks, '0'];
fs.writeFileSync(
path.join(processRoot, 'stat'),
`${processId} (node) ${fields.join(' ')}\n`,
);
fs.symlinkSync(executable, path.join(processRoot, 'exe'));
return digest;
}
function publishShutdownReceipt(
state,
processId,
startTicks,
startupReceiptDigest,
) {
const payload = {
schemaVersion: 1,
schema: 'qinglong/local-application-shutdown-receipt@v1',
instanceId: 'edge-router-1',
profile: 'edge',
signal: 'SIGTERM',
stopResult: 'stopped',
startupReceiptDigest,
bootId: '00000000-0000-4000-8000-000000000001',
stoppedBootAgeMs: 2000,
processId,
processStartTicks: startTicks,
nodeExecutable: fs.realpathSync(process.execPath),
nodeVersion: 'v24.18.0',
};
const digest = crypto
.createHash('sha256')
.update('qinglong.local-application-shutdown-receipt.v1\0', 'utf8')
.update(JSON.stringify(payload), 'utf8')
.digest('hex');
writePrivate(`${state.applicationPath}.stopped.json`, {
...payload,
sha256: digest,
});
return digest;
}
function consumeCommand(state, prepared) {
return {
schemaVersion: 1,
operation: 'local.deployment.service-manager.cutover.consume',
options: {
deploymentRoot: state.root,
allowRootService: process.getuid() === 0,
startupTimeoutMs: 100,
startupPollMs: 10,
},
request: {
actionId: prepared.actionId,
expectedIntentDigest: prepared.intentDigest,
},
};
}
test('commits adopted service active evidence and replays from the instance head', async (t) => {
const state = fixture(t);
const prepared = prepare(
state,
1,
'install-enable-start',
state.commitmentDigest,
'123e4567-e89b-42d3-a456-426614174021',
);
publishOutcome(
prepared,
'install-enable-start',
'active',
4123,
1786416000200,
);
const receiptDigest = publishReceipt(state, 4123, '100001');
const command = consumeCommand(state, prepared);
const result = await consumeLocalServiceManagerCutoverOutcome(command, {
procRoot: state.procRoot,
});
assert.equal(result.status, 'prepared');
assert.equal(result.state, 'target_active');
const head = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(head.state, 'target_active');
assert.equal(head.sourceRecordDigest, result.recordDigest);
const recordPath = path.join(
state.root,
'service',
'cutovers',
state.cutoverId,
'service-manager-g01-active.json',
);
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(
(
await consumeLocalServiceManagerCutoverOutcome(command, {
procRoot: state.procRoot,
})
).status,
'existing',
);
});
test('restart cannot reuse the previous generation startup receipt', async (t) => {
const state = fixture(t);
const first = prepare(
state,
1,
'install-enable-start',
state.commitmentDigest,
'123e4567-e89b-42d3-a456-426614174022',
);
publishOutcome(first, 'install-enable-start', 'active', 4223, 1786416000200);
publishReceipt(state, 4223, '100002');
const firstResult = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, first),
{ procRoot: state.procRoot },
);
const restart = prepare(
state,
2,
'restart',
firstResult.recordDigest,
'123e4567-e89b-42d3-a456-426614174023',
);
publishOutcome(restart, 'restart', 'active', 5223, 1786416000300);
let clock = 0;
const result = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, restart),
{
procRoot: state.procRoot,
now: () => clock,
wait: async (milliseconds) => {
clock += milliseconds;
},
},
);
assert.equal(result.state, 'manual_required');
const head = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(head.state, 'manual_required');
});
test('stop advances only after the exact receipted process identity disappears', async (t) => {
const state = fixture(t);
const first = prepare(
state,
1,
'install-enable-start',
state.commitmentDigest,
'123e4567-e89b-42d3-a456-426614174024',
);
publishOutcome(first, 'install-enable-start', 'active', 4323, 1786416000200);
const startupReceiptDigest = publishReceipt(state, 4323, '100003');
const firstResult = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, first),
{ procRoot: state.procRoot },
);
const stopped = prepare(
state,
1,
'stop',
firstResult.recordDigest,
'123e4567-e89b-42d3-a456-426614174025',
);
publishOutcome(stopped, 'stop', 'stopped', 0, 1786416000300);
const shutdownReceiptDigest = publishShutdownReceipt(
state,
4323,
'100003',
startupReceiptDigest,
);
fs.rmSync(path.join(state.procRoot, '4323'), {
recursive: true,
force: true,
});
const result = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, stopped),
{ procRoot: state.procRoot },
);
assert.equal(result.state, 'target_stopped');
const head = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(head.state, 'target_stopped');
assert.equal(head.sourceRecordDigest, result.recordDigest);
const record = JSON.parse(
fs.readFileSync(
path.join(
state.root,
'service',
'cutovers',
state.cutoverId,
'service-manager-g01-stopped.json',
),
'utf8',
),
);
assert.equal(record.evidence.shutdownReceiptDigest, shutdownReceiptDigest);
});
test('stop without an exact shutdown receipt requires manual resolution', async (t) => {
const state = fixture(t);
const first = prepare(
state,
1,
'install-enable-start',
state.commitmentDigest,
'123e4567-e89b-42d3-a456-426614174028',
);
publishOutcome(first, 'install-enable-start', 'active', 4623, 1786416000200);
publishReceipt(state, 4623, '100006');
const firstResult = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, first),
{ procRoot: state.procRoot },
);
const stopped = prepare(
state,
1,
'stop',
firstResult.recordDigest,
'123e4567-e89b-42d3-a456-426614174029',
);
publishOutcome(stopped, 'stop', 'stopped', 0, 1786416000300);
fs.rmSync(path.join(state.procRoot, '4623'), {
recursive: true,
force: true,
});
const result = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, stopped),
{ procRoot: state.procRoot },
);
assert.equal(result.state, 'manual_required');
});
test('rejects legacy source content drift before committing service active', async (t) => {
const state = fixture(t);
const prepared = prepare(
state,
1,
'install-enable-start',
state.commitmentDigest,
'123e4567-e89b-42d3-a456-426614174026',
);
publishOutcome(
prepared,
'install-enable-start',
'active',
4423,
1786416000200,
);
publishReceipt(state, 4423, '100004');
fs.writeFileSync(state.sourcePath, 'legacy-drifted\n', { mode: 0o600 });
await assert.rejects(
consumeLocalServiceManagerCutoverOutcome(consumeCommand(state, prepared), {
procRoot: state.procRoot,
}),
/adopted data evidence drifted/,
);
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(
state,
1,
'install-enable-start',
state.commitmentDigest,
'123e4567-e89b-42d3-a456-426614174027',
);
publishOutcome(
prepared,
'install-enable-start',
'active',
4523,
1786416000200,
);
publishReceipt(state, 4524, '100005');
let clock = 0;
const result = await consumeLocalServiceManagerCutoverOutcome(
consumeCommand(state, prepared),
{
procRoot: state.procRoot,
now: () => clock,
wait: async (milliseconds) => {
clock += milliseconds;
},
},
);
assert.equal(result.state, 'manual_required');
const head = readLocalCutoverInstanceHead(
state.root,
'edge-router-1',
process.getuid(),
);
assert.equal(head.state, 'manual_required');
});