fix(ql3): preserve adopted baseline during capture

This commit is contained in:
whyour
2026-09-01 12:18:28 +08:00
parent 367ec49e4a
commit 0235973c9b
4 changed files with 129 additions and 1 deletions
@@ -1,9 +1,14 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import {
adoptedTargetBaselinePath,
readAdoptedTargetBaseline,
} from '../cutover/targetBaseline';
import { cutoverDigest } from '../cutover/targetEvidence';
import {
readTargetDataReconciliationEvidenceForPaths,
@@ -177,6 +182,11 @@ export function proveLocalReconciliationStoppedState(
command.request.stoppedAuthority === 'docker'
? dockerStoppedEvidence(command)
: (serviceManagerStoppedRecord(command), undefined);
const adoptedTargetBaseline =
persisted?.baselineKind === 'adopted_target' &&
persisted.baselineDigest !== undefined
? readAdoptedTargetBaselineProjection(command, persisted.baselineDigest)
: undefined;
const current = readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
@@ -184,6 +194,7 @@ export function proveLocalReconciliationStoppedState(
legacySourcePath: command.request.legacySourcePath,
targetDatabasePath: command.request.targetDatabasePath,
expectedActivationDigest: command.request.expectedActivationDigest,
...(adoptedTargetBaseline === undefined ? {} : { adoptedTargetBaseline }),
},
uid,
);
@@ -203,3 +214,39 @@ export function proveLocalReconciliationStoppedState(
});
return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) });
}
function readAdoptedTargetBaselineProjection(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
expectedBaselineDigest: string,
): Readonly<{
baselineDigest: string;
targetDevice: string;
targetInode: string;
targetSha256: string;
}> {
const baseline = readAdoptedTargetBaseline(
adoptedTargetBaselinePath(command.options.deploymentRoot),
);
const targetPathDigest = crypto
.createHash('sha256')
.update(command.request.targetDatabasePath, 'utf8')
.digest('hex');
if (
baseline.profile !== command.request.profile ||
baseline.instanceId !== command.request.instanceId ||
baseline.cutoverId !== command.request.cutoverId ||
baseline.activationDigest !== command.request.expectedActivationDigest ||
baseline.targetPathDigest !== targetPathDigest ||
baseline.baselineDigest !== expectedBaselineDigest
) {
configurationError(
'adopted target baseline is detached from stopped evidence',
);
}
return Object.freeze({
baselineDigest: baseline.baselineDigest,
targetDevice: baseline.targetDevice,
targetInode: baseline.targetInode,
targetSha256: baseline.targetSha256,
});
}
@@ -66,6 +66,10 @@ const {
const {
readTargetDataReconciliationEvidenceForPaths,
} = require('../dist/deployment/cutover/targetDataEvidence.js');
const {
adoptedTargetBaselinePath,
createAdoptedTargetBaseline,
} = require('../dist/deployment/cutover/targetBaseline.js');
const {
targetRunJournalRecord,
targetStopPhasePath,
@@ -121,6 +125,7 @@ function fixture(
stoppedAuthority = 'docker',
profile = 'edge',
createDefaultSidecars = true,
useAdoptedTargetBaseline = false,
initializeDatabases,
mutateTarget,
} = {},
@@ -323,6 +328,32 @@ function fixture(
};
const applicationContents = `${JSON.stringify(application, null, 2)}\n`;
fs.writeFileSync(applicationConfigPath, applicationContents, { mode: 0o600 });
let adoptedTargetBaseline;
if (useAdoptedTargetBaseline) {
adoptedTargetBaseline = createAdoptedTargetBaseline({
preparedAtMs: 1_350,
profile,
instanceId: 'edge-router-1',
cutoverId,
activationDigest,
commitmentDigest,
applicationConfigDigest: crypto
.createHash('sha256')
.update(applicationContents, 'utf8')
.digest('hex'),
legacyDataApplicationCommitDigest: dataCommit.commitDigest,
legacyDataApplicationReceiptDigest: dataCommit.receiptDigest,
targetPathDigest: activationPayload.targetPathDigest,
targetDevice: activationPayload.targetDevice,
targetInode: activationPayload.targetInode,
targetSha256: activationPayload.targetSha256,
});
fs.writeFileSync(
adoptedTargetBaselinePath(deploymentRoot),
`${JSON.stringify(adoptedTargetBaseline)}\n`,
{ mode: 0o600 },
);
}
const adoptedBundlePayload = {
schemaVersion: 1,
kind: 'qinglong3-local-adopted-deployment-bundle',
@@ -397,6 +428,16 @@ function fixture(
legacySourcePath,
targetDatabasePath,
expectedActivationDigest: activationDigest,
...(adoptedTargetBaseline === undefined
? {}
: {
adoptedTargetBaseline: {
baselineDigest: adoptedTargetBaseline.baselineDigest,
targetDevice: adoptedTargetBaseline.targetDevice,
targetInode: adoptedTargetBaseline.targetInode,
targetSha256: adoptedTargetBaseline.targetSha256,
},
}),
},
uid,
);
@@ -514,6 +555,8 @@ function fixture(
legacySourcePath,
targetDatabasePath,
recoveryPath,
adoptedTargetBaseline,
reconciliation,
};
}
@@ -587,6 +630,42 @@ test('capture prepare rejects rollback-candidate stopped data', (t) => {
);
});
test('capture prepare preserves exact adopted-target baseline evidence', (t) => {
const state = fixture(t, {
createDefaultSidecars: false,
useAdoptedTargetBaseline: true,
});
assert.ok(state.adoptedTargetBaseline);
assert.equal(state.reconciliation.baselineKind, 'adopted_target');
assert.equal(
state.reconciliation.baselineDigest,
state.adoptedTargetBaseline.baselineDigest,
);
assert.equal(state.reconciliation.targetMatchesBaseline, false);
const prepared = prepareLocalReconciliationCapture(state.command);
assert.equal(prepared.status, 'prepared');
assert.equal(prepared.state, 'reconciliation_capture_prepared');
});
test('capture prepare rejects a valid but detached adopted-target baseline', (t) => {
const state = fixture(t, {
createDefaultSidecars: false,
useAdoptedTargetBaseline: true,
});
const baselinePath = adoptedTargetBaselinePath(state.deploymentRoot);
const detachedBaseline = createAdoptedTargetBaseline({
...state.adoptedTargetBaseline,
targetSha256: 'f'.repeat(64),
});
fs.writeFileSync(baselinePath, `${JSON.stringify(detachedBaseline)}\n`, {
mode: 0o600,
});
assert.throws(
() => prepareLocalReconciliationCapture(state.command),
/adopted target baseline is detached from stopped evidence/,
);
});
test('service-manager stopped authority uses the same capture fence', (t) => {
const state = fixture(t, { stoppedAuthority: 'service-manager' });
const prepared = prepareLocalReconciliationCapture(state.command);