Files

322 lines
9.7 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const {
normalizeLocalDeploymentLegacyReadinessCommand,
proveLocalDeploymentLegacyReadiness,
} = require('../dist/deployment/localDeployment.js');
const {
probeLegacySystemEndpoint,
} = require('../dist/deployment/cutover/legacy-readiness/probe.js');
const {
advanceLocalCutoverInstanceHead,
claimLocalCutoverInstance,
readLocalCutoverInstanceHead,
} = require('../dist/deployment/cutover/instanceLineage.js');
function rootAcknowledgement() {
return typeof process.getuid === 'function' && process.getuid() === 0;
}
function fixture(t, profile = 'edge', instanceId = `${profile}-legacy-1`) {
const deploymentRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-legacy-readiness-')),
);
fs.chmodSync(deploymentRoot, 0o700);
fs.mkdirSync(path.join(deploymentRoot, 'service'), { mode: 0o700 });
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
const identity = {
options: { deploymentRoot },
request: {
cutoverId: `cutover-${instanceId}`,
profile,
instanceId,
expectedActivationDigest: 'a'.repeat(64),
requestedAtMs: 1_787_200_000_000,
},
};
const uid = process.getuid();
claimLocalCutoverInstance(identity, uid, '0'.repeat(64));
const transitions = [
['legacy_stopped', 0, '1'.repeat(64)],
['target_active', 1, '2'.repeat(64)],
['target_stopped', 1, '3'.repeat(64)],
['rollback_prepared', 1, '4'.repeat(64)],
['legacy_restart_requested', 1, '5'.repeat(64)],
['legacy_running', 1, '6'.repeat(64)],
];
let head;
for (const [state, generation, sourceDigest] of transitions) {
head = advanceLocalCutoverInstanceHead(
identity,
uid,
state,
generation,
sourceDigest,
);
}
const command = {
schemaVersion: 1,
operation: 'local.deployment.cutover.legacy-readiness-probe',
options: {
deploymentRoot,
allowRootService: rootAcknowledgement(),
},
request: {
cutoverId: identity.request.cutoverId,
profile,
instanceId,
generation: 1,
expectedActivationDigest: identity.request.expectedActivationDigest,
expectedInstanceHeadDigest: head.headDigest,
expectedLegacyRunningRecordDigest: '6'.repeat(64),
legacyHttpPort: 5700,
expectedLegacyVersion: '2.21.0',
requestedAtMs: 1_787_200_030_000,
},
};
return { command, deploymentRoot, head, uid };
}
test('normalizes a closed legacy readiness command', (t) => {
const state = fixture(t);
const normalized = normalizeLocalDeploymentLegacyReadinessCommand(
state.command,
);
assert.equal(normalized.request.legacyHttpPort, 5700);
assert.equal(normalized.request.expectedLegacyVersion, '2.21.0');
assert.throws(
() =>
normalizeLocalDeploymentLegacyReadinessCommand({
...state.command,
request: { ...state.command.request, endpoint: 'http://example.com' },
}),
/request shape is invalid/,
);
assert.throws(
() =>
normalizeLocalDeploymentLegacyReadinessCommand({
...state.command,
request: { ...state.command.request, expectedLegacyVersion: '3.0.0' },
}),
/request identity is invalid/,
);
assert.throws(
() =>
normalizeLocalDeploymentLegacyReadinessCommand({
...state.command,
request: { ...state.command.request, legacyHttpPort: 65_536 },
}),
/legacyHttpPort is invalid/,
);
});
test('persists a legacy-ready receipt and replays without network authority', async (t) => {
const state = fixture(t);
let attempts = 0;
let clock = state.command.request.requestedAtMs;
const result = await proveLocalDeploymentLegacyReadiness(state.command, {
now: () => clock,
wait: async (milliseconds) => {
clock += milliseconds;
},
async probe(input) {
attempts += 1;
assert.deepEqual(
{ host: input.host, port: input.port, path: input.path },
{ host: '127.0.0.1', port: 5700, path: '/api/system' },
);
return attempts < 3
? { ready: false, reason: 'not_initialized' }
: { ready: true, initialized: true, version: '2.21.0' };
},
});
assert.equal(result.status, 'prepared');
assert.equal(result.state, 'legacy_ready');
assert.equal(result.attempts, 3);
assert.match(result.receiptDigest, /^[0-9a-f]{64}$/);
const receiptPath = path.join(
state.deploymentRoot,
'service',
'cutover-instances',
state.command.request.instanceId,
'legacy-readiness-g1.json',
);
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
assert.equal(receipt.state, 'legacy_ready');
assert.equal(receipt.endpoint.host, '127.0.0.1');
assert.equal(receipt.endpoint.path, '/api/system');
assert.equal(receipt.observedVersion, '2.21.0');
assert.equal(fs.statSync(receiptPath).mode & 0o777, 0o600);
const head = readLocalCutoverInstanceHead(
state.deploymentRoot,
state.command.request.instanceId,
state.uid,
);
assert.equal(head.state, 'legacy_ready');
assert.equal(head.previousHeadDigest, state.head.headDigest);
assert.equal(head.sourceRecordDigest, result.receiptDigest);
const replay = await proveLocalDeploymentLegacyReadiness(state.command, {
async probe() {
throw new Error('exact replay must not open loopback HTTP authority');
},
});
assert.equal(replay.status, 'existing');
assert.equal(replay.receiptDigest, result.receiptDigest);
assert.equal(replay.instanceHeadDigest, result.instanceHeadDigest);
});
test('bounds an unavailable edge probe without mutating lineage', async (t) => {
const state = fixture(t);
let clock = state.command.request.requestedAtMs;
let calls = 0;
const result = await proveLocalDeploymentLegacyReadiness(state.command, {
now: () => clock,
wait: async (milliseconds) => {
clock += milliseconds;
},
async probe() {
calls += 1;
return { ready: false, reason: 'unavailable' };
},
});
assert.equal(result.status, 'not_ready');
assert.equal(result.state, 'legacy_running');
assert.equal(result.reason, 'unavailable');
assert.equal(result.attempts, 60);
assert.equal(calls, 60);
assert.equal(
readLocalCutoverInstanceHead(
state.deploymentRoot,
state.command.request.instanceId,
state.uid,
).headDigest,
state.head.headDigest,
);
assert.equal(
fs.existsSync(
path.join(
state.deploymentRoot,
'service',
'cutover-instances',
state.command.request.instanceId,
'legacy-readiness-g1.json',
),
),
false,
);
});
test('fails a version mismatch and stale head before durable mutation', async (t) => {
const state = fixture(t);
const mismatch = await proveLocalDeploymentLegacyReadiness(state.command, {
async probe() {
return { ready: true, initialized: true, version: '2.20.0' };
},
});
assert.equal(mismatch.status, 'not_ready');
assert.equal(mismatch.reason, 'version_mismatch');
assert.equal(mismatch.attempts, 1);
let calls = 0;
await assert.rejects(
proveLocalDeploymentLegacyReadiness(
{
...state.command,
request: {
...state.command.request,
expectedInstanceHeadDigest: 'f'.repeat(64),
},
},
{
async probe() {
calls += 1;
return { ready: true, initialized: true, version: '2.21.0' };
},
},
),
/lost the instance head compare-and-swap/,
);
assert.equal(calls, 0);
});
test('probes only the fixed loopback system endpoint with bounded parsing', async (t) => {
const paths = [];
const server = http.createServer((request, response) => {
paths.push(request.url);
response.setHeader('content-type', 'application/json');
response.end(
JSON.stringify({
code: 200,
data: { isInitialized: true, version: '2.21.0' },
}),
);
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
const address = server.address();
const result = await probeLegacySystemEndpoint({
host: '127.0.0.1',
port: address.port,
path: '/api/system',
timeoutMs: 2_000,
maxResponseBytes: 32 * 1024,
});
assert.deepEqual(result, {
ready: true,
initialized: true,
version: '2.21.0',
});
assert.deepEqual(paths, ['/api/system']);
});
test('rejects oversized and redirect responses without following them', async (t) => {
let redirected = false;
const server = http.createServer((request, response) => {
if (request.url === '/redirected') {
redirected = true;
response.end('unexpected');
return;
}
if (request.url === '/large') {
response.end('x'.repeat(2_048));
return;
}
response.writeHead(302, { location: '/redirected' });
response.end();
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
const address = server.address();
const redirect = await probeLegacySystemEndpoint({
host: '127.0.0.1',
port: address.port,
path: '/api/system',
timeoutMs: 2_000,
maxResponseBytes: 1_024,
});
assert.deepEqual(redirect, { ready: false, reason: 'http_rejected' });
assert.equal(redirected, false);
const large = await probeLegacySystemEndpoint({
host: '127.0.0.1',
port: address.port,
path: '/large',
timeoutMs: 2_000,
maxResponseBytes: 1_024,
});
assert.deepEqual(large, {
ready: false,
reason: 'response_too_large',
});
});