feat(ql3): persist manual primary activation

This commit is contained in:
whyour
2026-08-19 05:13:58 +08:00
parent 9981f47851
commit 36035ac43e
15 changed files with 1191 additions and 43 deletions
@@ -24,6 +24,8 @@ function loadResult(status, mode = 'off') {
evaluatedAtMs: NOW,
sourcePath: '/data/config/qinglong3-rollout.json',
status,
revision: 'manual-primary-test',
sourceSha256: 'a'.repeat(64),
},
...(status === 'accepted'
? {
@@ -150,6 +152,20 @@ test('accepted bootstrap lazily loads the stack and delegates activation', async
calls.push('install');
return () => calls.push('dispose');
},
receipt: {
async activated() {
calls.push('receipt:active');
},
async stopping() {
calls.push('receipt:stopping');
},
async stopped() {
calls.push('receipt:stopped');
},
async failed() {
calls.push('receipt:failed');
},
},
audit(record) {
calls.push(`audit:${record.activation}`);
},
@@ -168,11 +184,14 @@ test('accepted bootstrap lazily loads the stack and delegates activation', async
'start-timeout',
'start-cancellation',
'install',
'receipt:active',
'audit:activated',
'receipt:stopping',
'dispose',
'stop-timeout',
'stop-cancellation',
'stop-completion',
'receipt:stopped',
'audit:stopped',
]);
});
@@ -25,6 +25,7 @@ function loadResult(status, mode = 'off') {
sourcePath: '/data/config/qinglong3-rollout.json',
status,
revision: 'canary-1',
sourceSha256: 'a'.repeat(64),
},
};
}
@@ -171,6 +172,138 @@ test('activation reconciles before starting lifecycle and installing ownership',
]);
});
test('activation publishes durable state around ownership and lifecycle shutdown', async () => {
const calls = [];
const result = await activateManualPrimaryRuntime({
load: async () => loadResult('accepted', 'primary'),
create() {
return {
router: router(),
...completionLifecycle(calls),
async reconcile() {
return cleanRecovery();
},
startTimeout() {
calls.push('start-timeout');
return true;
},
async stopTimeout() {
calls.push('stop-timeout');
return 'drained';
},
startCancellation() {
calls.push('start-cancellation');
return true;
},
async stopCancellation() {
calls.push('stop-cancellation');
return 'drained';
},
};
},
install() {
calls.push('install');
return () => calls.push('dispose');
},
receipt: {
async activated() {
calls.push('receipt:active');
},
async stopping() {
calls.push('receipt:stopping');
},
async stopped() {
calls.push('receipt:stopped');
},
async failed() {
calls.push('receipt:failed');
},
},
audit(record) {
calls.push(`audit:${record.activation}`);
},
});
assert.deepEqual(calls.slice(-4), [
'start-cancellation',
'install',
'receipt:active',
'audit:activated',
]);
await result.stop();
assert.deepEqual(calls.slice(-7), [
'receipt:stopping',
'dispose',
'stop-timeout',
'stop-cancellation',
'stop-completion',
'receipt:stopped',
'audit:stopped',
]);
});
test('activation rolls ownership back when durable receipt publication fails', async () => {
const calls = [];
await assert.rejects(
activateManualPrimaryRuntime({
load: async () => loadResult('accepted', 'primary'),
create() {
return {
router: router(),
...completionLifecycle(calls),
async reconcile() {
return cleanRecovery();
},
startTimeout() {
calls.push('start-timeout');
return true;
},
async stopTimeout() {
calls.push('stop-timeout');
return 'drained';
},
startCancellation() {
calls.push('start-cancellation');
return true;
},
async stopCancellation() {
calls.push('stop-cancellation');
return 'drained';
},
};
},
install() {
calls.push('install');
return () => calls.push('dispose');
},
receipt: {
async activated() {
calls.push('receipt:active');
throw new Error('receipt unavailable');
},
async stopping() {},
async stopped() {},
async failed() {
calls.push('receipt:failed');
},
},
audit(record) {
calls.push(`audit:${record.activation}`);
},
}),
/receipt unavailable/,
);
assert.deepEqual(calls.slice(-7), [
'receipt:active',
'dispose',
'stop-timeout',
'stop-cancellation',
'stop-completion',
'receipt:failed',
'audit:failed',
]);
});
test('activation rejects unresolved recovery before starting or installing', async () => {
const calls = [];
await assert.rejects(
@@ -0,0 +1,157 @@
require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const {
MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE,
parseManualPrimaryRuntimeReceipt,
} = require('../../back/runtime/domain/manualPrimaryRuntimeReceipt');
const {
ManualPrimaryRuntimeReceiptConflictError,
ManualPrimaryRuntimeReceiptStore,
} = require('../../back/runtime/adapters/fs/manualPrimaryRuntimeReceiptStore');
const IDENTITY = {
platform: 'linux',
bootId: '11111111-2222-3333-4444-555555555555',
pid: 321,
processGroupId: 320,
startTimeTicks: '123456',
};
function audit() {
return {
event: 'runtime.rollout_config_evaluated',
evaluatedAtMs: 1_000,
sourcePath: '/data/config/qinglong3-rollout.json',
sourceSha256: 'a'.repeat(64),
revision: 'manual-primary-edge-live-1',
status: 'accepted',
};
}
function fixture(t, inspection = 'exited') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-runtime-receipt-'));
fs.chmodSync(root, 0o700);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
let now = 10_000;
const options = {
clock: { now: () => now++ },
platform: 'linux',
pid: IDENTITY.pid,
randomId: () => '1'.repeat(32),
identityProvider: {
async capture() {
return IDENTITY;
},
async inspect() {
return { status: inspection };
},
},
};
return {
root,
store: new ManualPrimaryRuntimeReceiptStore(root, 'edge', options),
options,
};
}
test('publishes one private current receipt and transitions it around shutdown', async (t) => {
const { root, store } = fixture(t);
const target = path.join(root, MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE);
await store.activated(audit());
let receipt = parseManualPrimaryRuntimeReceipt(
JSON.parse(fs.readFileSync(target, 'utf8')),
);
assert.equal(receipt.state, 'active');
assert.equal(receipt.process.kind, 'linux-proc');
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
await store.stopping();
receipt = parseManualPrimaryRuntimeReceipt(
JSON.parse(fs.readFileSync(target, 'utf8')),
);
assert.equal(receipt.state, 'stopping');
await store.stopped();
receipt = parseManualPrimaryRuntimeReceipt(
JSON.parse(fs.readFileSync(target, 'utf8')),
);
assert.equal(receipt.state, 'stopped');
assert.equal(receipt.activationId, '1'.repeat(32));
});
test('refuses to replace a receipt whose exact Linux process is still live', async (t) => {
const first = fixture(t);
await first.store.activated(audit());
const second = new ManualPrimaryRuntimeReceiptStore(first.root, 'edge', {
...first.options,
randomId: () => '2'.repeat(32),
identityProvider: {
...first.options.identityProvider,
async inspect() {
return { status: 'running' };
},
},
});
await assert.rejects(
second.activated(audit()),
ManualPrimaryRuntimeReceiptConflictError,
);
});
test('replaces a stale process generation and rejects receipt tampering', async (t) => {
const first = fixture(t);
await first.store.activated(audit());
const second = new ManualPrimaryRuntimeReceiptStore(first.root, 'edge', {
...first.options,
randomId: () => '2'.repeat(32),
});
await second.activated(audit());
const target = path.join(first.root, MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE);
const receipt = JSON.parse(fs.readFileSync(target, 'utf8'));
assert.equal(receipt.activationId, '2'.repeat(32));
receipt.state = 'stopped';
assert.throws(
() => parseManualPrimaryRuntimeReceipt(receipt),
/digest is invalid/,
);
});
test('portable receipts remain observable but cannot claim Linux liveness', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-runtime-receipt-'));
fs.chmodSync(root, 0o700);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const store = new ManualPrimaryRuntimeReceiptStore(root, 'standalone', {
clock: { now: () => 20_000 },
platform: 'darwin',
pid: 432,
randomId: () => '3'.repeat(32),
identityProvider: {
async capture() {
return null;
},
async inspect() {
return { status: 'unsupported' };
},
},
});
await store.activated(audit());
const receipt = parseManualPrimaryRuntimeReceipt(
JSON.parse(
fs.readFileSync(
path.join(root, MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE),
'utf8',
),
),
);
assert.deepEqual(receipt.process, {
kind: 'portable',
platform: 'darwin',
pid: 432,
});
});
+67
View File
@@ -8,6 +8,10 @@ const { afterEach, test } = require('node:test');
const {
manualPrimaryCanaryFileSet,
} = require('../../back/runtime/domain/manualPrimaryCanaryCeremony');
const {
createManualPrimaryRuntimeReceipt,
MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE,
} = require('../../back/runtime/domain/manualPrimaryRuntimeReceipt');
const {
parseArguments,
readPrivateJson,
@@ -273,6 +277,35 @@ test('qualifies, explicitly approves, audits and rolls back one target session',
.runtimeActivationObserved,
false,
);
const rolloutSha256 = readPrivateJson(
path.join(root, files.rollout),
64 * 1024,
).sha256;
writeJson(
path.join(root, MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE),
createManualPrimaryRuntimeReceipt({
activationId: '1'.repeat(32),
profile: 'edge',
revision: `manual-primary-${SESSION}`,
rolloutSourceSha256: rolloutSha256,
activatedAtMs: activatedAt + 1,
process: {
kind: 'linux-proc',
platform: 'linux',
pid: 999_999,
processGroupId: 999_999,
bootId: '11111111-2222-3333-4444-555555555555',
startTimeTicks: '123456',
},
}),
);
const active = audit(
{ root, sessionId: SESSION, require: 'active' },
{ inspectRuntimeProcess: () => 'running' },
);
assert.equal(active.runtimeActivationObserved, true);
assert.equal(active.runtimeActivationCurrent, true);
assert.equal(active.runtimeReceiptState, 'active');
assert.equal(
run(
{ mode: 'status', root, sessionId: SESSION },
@@ -323,6 +356,40 @@ test('qualifies, explicitly approves, audits and rolls back one target session',
).publication,
'existing',
);
assert.throws(
() =>
audit(
{ root, sessionId: SESSION, require: 'rolled-back' },
{ inspectRuntimeProcess: () => 'running' },
),
/not satisfied/,
);
writeJson(
path.join(root, MANUAL_PRIMARY_RUNTIME_RECEIPT_FILE),
createManualPrimaryRuntimeReceipt({
activationId: '2'.repeat(32),
profile: 'edge',
revision: 'manual-primary-another-session',
rolloutSourceSha256: 'b'.repeat(64),
activatedAtMs: activatedAt + 3,
process: {
kind: 'linux-proc',
platform: 'linux',
pid: 999_998,
processGroupId: 999_998,
bootId: '11111111-2222-3333-4444-555555555555',
startTimeTicks: '123457',
},
}),
);
assert.throws(
() =>
audit(
{ root, sessionId: SESSION, require: 'rolled-back' },
{ inspectRuntimeProcess: () => 'running' },
),
/not satisfied/,
);
assert.equal(
audit({ root, sessionId: SESSION, require: 'rolled-back' }).rolloutMode,
'off',