mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): fence reconciliation capture preparation
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
prepareLocalReconciliationCapture,
|
||||
} = require('../dist/deployment/localDeployment.js');
|
||||
const {
|
||||
advanceLocalCutoverInstanceHead,
|
||||
claimLocalCutoverInstance,
|
||||
readLocalCutoverInstanceHead,
|
||||
} = require('../dist/deployment/cutover/instanceLineage.js');
|
||||
const {
|
||||
readTargetDataReconciliationEvidenceForPaths,
|
||||
} = require('../dist/deployment/cutover/targetDataEvidence.js');
|
||||
const {
|
||||
targetRunJournalRecord,
|
||||
targetStopPhasePath,
|
||||
targetStopSequence,
|
||||
} = require('../dist/deployment/cutover/target-run/targetRunJournal.js');
|
||||
const {
|
||||
targetStoppedEvidence,
|
||||
} = require('../dist/deployment/cutover/targetStopRecordEvidence.js');
|
||||
|
||||
function digest(value) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function rootAcknowledgement() {
|
||||
return typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
}
|
||||
|
||||
function fixture(
|
||||
t,
|
||||
{ reconciliationRequired = true, stoppedAuthority = 'docker' } = {},
|
||||
) {
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-reconciliation-capture-')),
|
||||
);
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const deploymentRoot = path.join(root, 'runtime');
|
||||
const serviceRoot = path.join(deploymentRoot, 'service');
|
||||
const cutoverId = 'capture-cutover-1';
|
||||
const journal = path.join(serviceRoot, 'cutovers', cutoverId);
|
||||
const instanceRoot = path.join(serviceRoot, 'cutover-instances');
|
||||
const captureRoot = path.join(root, 'capture-root');
|
||||
for (const directory of [
|
||||
deploymentRoot,
|
||||
serviceRoot,
|
||||
path.dirname(journal),
|
||||
journal,
|
||||
instanceRoot,
|
||||
captureRoot,
|
||||
]) {
|
||||
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
|
||||
}
|
||||
const legacySourcePath = path.join(root, 'database.sqlite');
|
||||
const targetDatabasePath = path.join(root, 'database.ql3.sqlite');
|
||||
const recoveryPath = path.join(root, 'database.recovery.sqlite');
|
||||
const activationPath = path.join(root, 'activation.json');
|
||||
fs.writeFileSync(legacySourcePath, 'legacy-source\n', { mode: 0o600 });
|
||||
fs.writeFileSync(targetDatabasePath, 'target-initial\n', { mode: 0o600 });
|
||||
fs.writeFileSync(recoveryPath, 'legacy-source\n', { mode: 0o600 });
|
||||
const targetStat = fs.statSync(targetDatabasePath, { bigint: true });
|
||||
const activationPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-sqlite-activation',
|
||||
state: 'prepared',
|
||||
profile: 'edge',
|
||||
createdAtMs: 1_000,
|
||||
adoptionManifestDigest: '1'.repeat(64),
|
||||
planDigest: '2'.repeat(64),
|
||||
sourcePathDigest: crypto
|
||||
.createHash('sha256')
|
||||
.update(legacySourcePath, 'utf8')
|
||||
.digest('hex'),
|
||||
sourceSha256: crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(legacySourcePath))
|
||||
.digest('hex'),
|
||||
recoverySha256: crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(recoveryPath))
|
||||
.digest('hex'),
|
||||
targetSha256: crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(targetDatabasePath))
|
||||
.digest('hex'),
|
||||
targetPathDigest: crypto
|
||||
.createHash('sha256')
|
||||
.update(targetDatabasePath, 'utf8')
|
||||
.digest('hex'),
|
||||
targetDevice: targetStat.dev.toString(),
|
||||
targetInode: targetStat.ino.toString(),
|
||||
};
|
||||
const activationDigest = digest(activationPayload);
|
||||
fs.writeFileSync(
|
||||
activationPath,
|
||||
`${JSON.stringify({ ...activationPayload, activationDigest })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
if (reconciliationRequired) {
|
||||
fs.writeFileSync(targetDatabasePath, 'target-mutated\n', { mode: 0o600 });
|
||||
}
|
||||
const identity = {
|
||||
options: { deploymentRoot },
|
||||
request: {
|
||||
cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
expectedActivationDigest: activationDigest,
|
||||
requestedAtMs: 2_000,
|
||||
},
|
||||
};
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||
claimLocalCutoverInstance(identity, uid, '3'.repeat(64));
|
||||
advanceLocalCutoverInstanceHead(
|
||||
identity,
|
||||
uid,
|
||||
'legacy_stopped',
|
||||
0,
|
||||
'4'.repeat(64),
|
||||
);
|
||||
advanceLocalCutoverInstanceHead(
|
||||
identity,
|
||||
uid,
|
||||
'target_active',
|
||||
1,
|
||||
'5'.repeat(64),
|
||||
);
|
||||
const reconciliation = readTargetDataReconciliationEvidenceForPaths(
|
||||
{
|
||||
profile: 'edge',
|
||||
activationPath,
|
||||
legacySourcePath,
|
||||
targetDatabasePath,
|
||||
expectedActivationDigest: activationDigest,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
const runCommand = {
|
||||
request: {
|
||||
cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
expectedActivationDigest: activationDigest,
|
||||
generation: 1,
|
||||
requestedAtMs: 3_000,
|
||||
},
|
||||
};
|
||||
let stoppedRecord;
|
||||
if (stoppedAuthority === 'docker') {
|
||||
stoppedRecord = targetRunJournalRecord(
|
||||
runCommand,
|
||||
targetStopSequence(1, 'outcome'),
|
||||
'target_stopped',
|
||||
'6'.repeat(64),
|
||||
targetStoppedEvidence(
|
||||
{
|
||||
activeRecordDigest: '5'.repeat(64),
|
||||
targetContainerIdentityDigest: '7'.repeat(64),
|
||||
targetApplicationBindingDigest: '8'.repeat(64),
|
||||
startupReceiptDigest: '9'.repeat(64),
|
||||
},
|
||||
reconciliation,
|
||||
),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
targetStopPhasePath(journal, 1, 'outcome'),
|
||||
`${JSON.stringify(stoppedRecord)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
} else {
|
||||
const evidence = {
|
||||
managerOutcomeDigest: '6'.repeat(64),
|
||||
managerObservationDigest: '7'.repeat(64),
|
||||
applicationConfigDigest: '8'.repeat(64),
|
||||
activationDigest,
|
||||
commitmentDigest: '9'.repeat(64),
|
||||
targetDataIdentityDigest: 'a'.repeat(64),
|
||||
startupReceiptDigest: 'b'.repeat(64),
|
||||
shutdownReceiptDigest: 'c'.repeat(64),
|
||||
processIdentityDigest: 'd'.repeat(64),
|
||||
manualReason: null,
|
||||
};
|
||||
const payload = {
|
||||
schema: 'qinglong3-local-service-manager-cutover-record',
|
||||
schemaVersion: 1,
|
||||
actionId: '00000000-0000-4000-8000-000000000201',
|
||||
action: 'stop',
|
||||
state: 'target_stopped',
|
||||
cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
activationDigest,
|
||||
generation: 1,
|
||||
previousRecordDigest: '5'.repeat(64),
|
||||
intentDigest: 'e'.repeat(64),
|
||||
requestedAtMs: 2_500,
|
||||
completedAtMs: 3_000,
|
||||
evidence,
|
||||
};
|
||||
stoppedRecord = { ...payload, recordDigest: digest(payload) };
|
||||
fs.writeFileSync(
|
||||
path.join(journal, 'service-manager-g01-stopped.json'),
|
||||
`${JSON.stringify(stoppedRecord)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
const stoppedHead = advanceLocalCutoverInstanceHead(
|
||||
identity,
|
||||
uid,
|
||||
'target_stopped',
|
||||
1,
|
||||
stoppedRecord.recordDigest,
|
||||
);
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.capture.prepare',
|
||||
options: {
|
||||
deploymentRoot,
|
||||
captureRoot,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request: {
|
||||
captureId: '00000000-0000-4000-8000-000000000101',
|
||||
stoppedAuthority,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
cutoverId,
|
||||
generation: 1,
|
||||
activationPath,
|
||||
legacySourcePath,
|
||||
targetDatabasePath,
|
||||
recoveryPath,
|
||||
expectedActivationDigest: activationDigest,
|
||||
expectedHeadDigest: stoppedHead.headDigest,
|
||||
expectedStoppedRecordDigest: stoppedRecord.recordDigest,
|
||||
preparedAtMs: 4_000,
|
||||
},
|
||||
};
|
||||
return { command, deploymentRoot, captureRoot, identity, stoppedHead, uid };
|
||||
}
|
||||
|
||||
test('capture prepare establishes one replayable reconciliation fence', (t) => {
|
||||
const state = fixture(t);
|
||||
const prepared = prepareLocalReconciliationCapture(state.command);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.state, 'reconciliation_capture_prepared');
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
state.captureRoot,
|
||||
state.command.request.captureId,
|
||||
'intent.json',
|
||||
),
|
||||
),
|
||||
true,
|
||||
);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.command.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(head.state, 'reconciliation_capture_prepared');
|
||||
assert.equal(head.sourceRecordDigest, prepared.preparationDigest);
|
||||
|
||||
const replay = prepareLocalReconciliationCapture(state.command);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.instanceHeadDigest, prepared.instanceHeadDigest);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
advanceLocalCutoverInstanceHead(
|
||||
state.identity,
|
||||
state.uid,
|
||||
'rollback_prepared',
|
||||
1,
|
||||
'a'.repeat(64),
|
||||
),
|
||||
/transition is invalid/,
|
||||
);
|
||||
const conflicting = structuredClone(state.command);
|
||||
conflicting.request.captureId = '00000000-0000-4000-8000-000000000102';
|
||||
assert.throws(
|
||||
() => prepareLocalReconciliationCapture(conflicting),
|
||||
/another capture owns/,
|
||||
);
|
||||
});
|
||||
|
||||
test('capture prepare rejects rollback-candidate stopped data', (t) => {
|
||||
const state = fixture(t, { reconciliationRequired: false });
|
||||
assert.throws(
|
||||
() => prepareLocalReconciliationCapture(state.command),
|
||||
/reconciliation-required evidence/,
|
||||
);
|
||||
const head = readLocalCutoverInstanceHead(
|
||||
state.deploymentRoot,
|
||||
state.command.request.instanceId,
|
||||
state.uid,
|
||||
);
|
||||
assert.equal(head.headDigest, state.stoppedHead.headDigest);
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
state.captureRoot,
|
||||
state.command.request.captureId,
|
||||
'intent.json',
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('service-manager stopped authority uses the same capture fence', (t) => {
|
||||
const state = fixture(t, { stoppedAuthority: 'service-manager' });
|
||||
const prepared = prepareLocalReconciliationCapture(state.command);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.state, 'reconciliation_capture_prepared');
|
||||
const replay = prepareLocalReconciliationCapture(state.command);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.preparationDigest, prepared.preparationDigest);
|
||||
});
|
||||
|
||||
test('capture prepare CLI consumes a private command and emits no paths', (t) => {
|
||||
const state = fixture(t);
|
||||
const commandPath = path.join(state.deploymentRoot, 'capture-command.json');
|
||||
fs.writeFileSync(commandPath, `${JSON.stringify(state.command)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(__dirname, '../dist/deployment/localDeploymentCli.js'),
|
||||
'reconciliation-capture-prepare',
|
||||
'--command-file',
|
||||
commandPath,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.equal(output.state, 'reconciliation_capture_prepared');
|
||||
assert.equal(output.captureId, state.command.request.captureId);
|
||||
assert.equal(result.stdout.includes(state.captureRoot), false);
|
||||
assert.equal(result.stdout.includes(state.command.request.targetDatabasePath), false);
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
Reference in New Issue
Block a user