mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prove legacy rollback readiness
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user