fix(ql3): bind post-apply target baseline

This commit is contained in:
whyour
2026-08-30 21:11:47 +08:00
parent 419e1bc141
commit 784e77a971
10 changed files with 651 additions and 29 deletions
@@ -215,6 +215,17 @@ export function prepareLocalDeploymentAdoptedBundle(
identity.uid,
'adopted bundle receipt',
);
if (material.targetBaseline !== null) {
preflightPublishedFile(
material.paths.targetBaseline,
material.targetBaseline,
0o600,
identity.uid,
'adopted target baseline',
);
} else if (fs.existsSync(material.paths.targetBaseline)) {
configurationError('process service cannot inherit a target baseline');
}
if (material.composeSelection !== null) {
preflightPublishedFile(
material.paths.composeRevision,
@@ -309,6 +320,16 @@ export function prepareLocalDeploymentAdoptedBundle(
identity.uid,
'adopted bundle receipt',
);
const baselineStatus =
material.targetBaseline === null
? 'existing'
: publishExactFile(
material.paths.targetBaseline,
material.targetBaseline,
0o600,
identity.uid,
'adopted target baseline',
);
const createdDirectories = directoryStatuses.filter(
(status) => status === 'prepared',
).length;
@@ -318,6 +339,7 @@ export function prepareLocalDeploymentAdoptedBundle(
serviceStatus === 'prepared' ||
composeRevisionStatus === 'prepared' ||
composeSelectionStatus === 'prepared' ||
baselineStatus === 'prepared' ||
receiptStatus === 'prepared';
return Object.freeze({
schemaVersion: 1 as const,
@@ -431,6 +453,18 @@ export function verifyLocalDeploymentAdoptedBundle(
identity.gid,
'adopted bundle receipt',
);
if (material.targetBaseline !== null) {
verifyPublishedFile(
material.paths.targetBaseline,
material.targetBaseline,
0o600,
identity.uid,
identity.gid,
'adopted target baseline',
);
} else if (fs.existsSync(material.paths.targetBaseline)) {
configurationError('process service cannot inherit a target baseline');
}
if (material.composeSelection !== null) {
verifyPublishedFile(
material.paths.composeRevision,
@@ -8,6 +8,10 @@ import {
} from '@qinglong/local-sqlite/data-directory-application-commit';
import { initialComposeImageSelectionFromAuthority } from '../compose/composeRevision';
import {
adoptedTargetBaselinePath,
createAdoptedTargetBaseline,
} from '../cutover/targetBaseline';
import { cutoverDigest } from '../cutover/targetEvidence';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import { composeProjectName } from '../foundation/render';
@@ -31,6 +35,7 @@ export interface LocalDeploymentAdoptedBundlePaths {
readonly applicationConfig: string;
readonly descriptor: string;
readonly bundleReceipt: string;
readonly targetBaseline: string;
readonly composeSelection: string;
readonly composeRevisions: string;
readonly composeRevision: string;
@@ -52,6 +57,7 @@ export interface LocalDeploymentAdoptedEvidence {
readonly manifestDigest: string;
readonly sourceSha256: string;
readonly recoverySha256: string;
readonly targetSha256: string;
readonly targetDevice: string;
readonly targetInode: string;
readonly targetIdentityDigest: string;
@@ -92,6 +98,7 @@ export interface LocalDeploymentAdoptedBundleMaterial {
mode: number;
}>;
readonly composeSelection: string | null;
readonly targetBaseline: string | null;
readonly receipt: Readonly<LocalDeploymentAdoptedBundleReceipt>;
readonly receiptContents: string;
}
@@ -323,6 +330,7 @@ export function adoptedBundlePaths(
applicationConfig: path.join(root, 'local-application.json'),
descriptor: path.join(service, descriptorName),
bundleReceipt: path.join(service, 'adopted-bundle.json'),
targetBaseline: adoptedTargetBaselinePath(root),
composeSelection: path.join(service, 'compose.image.yaml'),
composeRevisions: path.join(service, 'revisions'),
composeRevision: path.join(service, 'revisions', '1.yaml'),
@@ -538,6 +546,23 @@ export function verifyLocalDeploymentAdoptedEvidence(
gid,
'recovery database',
);
const targetSidecars = ['-wal', '-shm', '-journal'].map((suffix) =>
fs.existsSync(`${command.request.storage.targetPath}${suffix}`),
);
const targetSha256 = stableFileSha256(
command.request.storage.targetPath,
uid,
gid,
'target database',
);
if (
targetSidecars.some(Boolean) ||
['-wal', '-shm', '-journal'].some((suffix) =>
fs.existsSync(`${command.request.storage.targetPath}${suffix}`),
)
) {
configurationError('target database sidecars must be absent');
}
if (
activation.targetDevice !== target.device ||
activation.targetInode !== target.inode ||
@@ -554,6 +579,7 @@ export function verifyLocalDeploymentAdoptedEvidence(
manifestDigest,
sourceSha256,
recoverySha256,
targetSha256,
targetDevice: target.device,
targetInode: target.inode,
targetIdentityDigest: cutoverDigest({
@@ -872,6 +898,30 @@ export function renderLocalDeploymentAdoptedBundleMaterial(
changedAtMs: command.request.preparedAtMs,
})
: null;
const targetBaseline =
command.options.service.kind === 'docker-target'
? `${JSON.stringify(
createAdoptedTargetBaseline({
preparedAtMs: command.request.preparedAtMs,
profile: command.options.profile,
instanceId: command.options.instanceId,
cutoverId: command.request.cutoverId,
activationDigest: evidence.activationDigest,
commitmentDigest: evidence.commitmentDigest,
applicationConfigDigest: cutoverDigest(
JSON.parse(config) as unknown,
),
legacyDataApplicationCommitDigest: evidence.commitDigest,
legacyDataApplicationReceiptDigest: evidence.receiptDigest,
targetPathDigest: sha256(command.request.storage.targetPath),
targetDevice: evidence.targetDevice,
targetInode: evidence.targetInode,
targetSha256: evidence.targetSha256,
}),
null,
2,
)}\n`
: null;
const receiptPayload = {
schemaVersion: 1 as const,
kind: 'qinglong3-local-adopted-deployment-bundle' as const,
@@ -906,6 +956,7 @@ export function renderLocalDeploymentAdoptedBundleMaterial(
applicationConfig: config,
descriptor,
composeSelection,
targetBaseline,
receipt,
receiptContents: `${JSON.stringify(receipt, null, 2)}\n`,
});
@@ -0,0 +1,180 @@
import fs from 'node:fs';
import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import { cutoverDigest } from './targetEvidence';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export interface AdoptedTargetBaseline {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-local-adopted-target-baseline';
readonly state: 'prepared';
readonly preparedAtMs: number;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly cutoverId: string;
readonly activationDigest: string;
readonly commitmentDigest: string;
readonly applicationConfigDigest: string;
readonly legacyDataApplicationCommitDigest: string;
readonly legacyDataApplicationReceiptDigest: string;
readonly targetPathDigest: string;
readonly targetDevice: string;
readonly targetInode: string;
readonly targetSha256: string;
readonly targetSidecarsClear: true;
readonly baselineDigest: string;
}
export interface AdoptedTargetBaselineInput {
readonly preparedAtMs: number;
readonly profile: 'edge' | 'standalone';
readonly instanceId: string;
readonly cutoverId: string;
readonly activationDigest: string;
readonly commitmentDigest: string;
readonly applicationConfigDigest: string;
readonly legacyDataApplicationCommitDigest: string;
readonly legacyDataApplicationReceiptDigest: string;
readonly targetPathDigest: string;
readonly targetDevice: string;
readonly targetInode: string;
readonly targetSha256: string;
}
function configurationError(message: string): never {
throw new LocalDeploymentConfigurationError(message);
}
function object(value: unknown): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
configurationError('adopted target baseline must be an object');
}
return value as Record<string, unknown>;
}
function exact(value: Record<string, unknown>, keys: readonly string[]): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
configurationError('adopted target baseline shape is invalid');
}
}
export function adoptedTargetBaselinePath(deploymentRoot: string): string {
return path.join(deploymentRoot, 'service', 'adopted-target-baseline.json');
}
export function createAdoptedTargetBaseline(
input: Readonly<AdoptedTargetBaselineInput>,
): Readonly<AdoptedTargetBaseline> {
const payload = {
schemaVersion: 1 as const,
kind: 'qinglong3-local-adopted-target-baseline' as const,
state: 'prepared' as const,
preparedAtMs: input.preparedAtMs,
profile: input.profile,
instanceId: input.instanceId,
cutoverId: input.cutoverId,
activationDigest: input.activationDigest,
commitmentDigest: input.commitmentDigest,
applicationConfigDigest: input.applicationConfigDigest,
legacyDataApplicationCommitDigest: input.legacyDataApplicationCommitDigest,
legacyDataApplicationReceiptDigest:
input.legacyDataApplicationReceiptDigest,
targetPathDigest: input.targetPathDigest,
targetDevice: input.targetDevice,
targetInode: input.targetInode,
targetSha256: input.targetSha256,
targetSidecarsClear: true as const,
};
return Object.freeze({
...payload,
baselineDigest: cutoverDigest(payload),
});
}
export function readAdoptedTargetBaseline(
filePath: string,
): Readonly<AdoptedTargetBaseline> {
let baseline: Record<string, unknown>;
try {
const stat = fs.lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
fs.realpathSync(filePath) !== filePath
) {
configurationError('adopted target baseline identity is invalid');
}
baseline = object(readPrivateLocalCommandFile(filePath));
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
throw new LocalDeploymentConfigurationError(
'adopted target baseline cannot be read',
{ cause: error },
);
}
exact(baseline, [
'activationDigest',
'applicationConfigDigest',
'baselineDigest',
'commitmentDigest',
'cutoverId',
'instanceId',
'kind',
'legacyDataApplicationCommitDigest',
'legacyDataApplicationReceiptDigest',
'preparedAtMs',
'profile',
'schemaVersion',
'state',
'targetDevice',
'targetInode',
'targetPathDigest',
'targetSha256',
'targetSidecarsClear',
]);
const { baselineDigest, ...payload } = baseline;
if (
baseline.schemaVersion !== 1 ||
baseline.kind !== 'qinglong3-local-adopted-target-baseline' ||
baseline.state !== 'prepared' ||
!Number.isSafeInteger(baseline.preparedAtMs) ||
(baseline.preparedAtMs as number) < 0 ||
(baseline.profile !== 'edge' && baseline.profile !== 'standalone') ||
typeof baseline.instanceId !== 'string' ||
typeof baseline.cutoverId !== 'string' ||
typeof baseline.targetDevice !== 'string' ||
typeof baseline.targetInode !== 'string' ||
baseline.targetSidecarsClear !== true ||
[
baseline.activationDigest,
baseline.commitmentDigest,
baseline.applicationConfigDigest,
baseline.legacyDataApplicationCommitDigest,
baseline.legacyDataApplicationReceiptDigest,
baseline.targetPathDigest,
baseline.targetSha256,
baselineDigest,
].some(
(value) => typeof value !== 'string' || !DIGEST_PATTERN.test(value),
) ||
cutoverDigest(payload) !== baselineDigest
) {
configurationError('adopted target baseline identity drifted');
}
return baseline as unknown as Readonly<AdoptedTargetBaseline>;
}
@@ -5,7 +5,11 @@ import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import type { LocalDeploymentTargetReconciliationDisposition } from './targetStopContract';
import type { LocalDeploymentTargetRunCommand } from './target-run/targetRunContract';
import { cutoverDigest } from './targetEvidence';
import {
adoptedTargetBaselinePath,
readAdoptedTargetBaseline,
} from './targetBaseline';
import { cutoverDigest, readTargetApplicationBinding } from './targetEvidence';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UNKNOWN_DIGEST = '0'.repeat(64);
@@ -19,6 +23,9 @@ export interface TargetDataReconciliationEvidence {
readonly sourceSidecarsClear: boolean | null;
readonly targetFileIdentityDigest: string;
readonly sourceFileIdentityDigest: string;
readonly baselineKind?: 'adopted_target';
readonly baselineDigest?: string;
readonly targetMatchesBaseline?: boolean | null;
readonly evidenceDigest: string;
}
@@ -28,6 +35,12 @@ export interface TargetDataReconciliationInput {
readonly legacySourcePath: string;
readonly targetDatabasePath: string;
readonly expectedActivationDigest: string;
readonly adoptedTargetBaseline?: Readonly<{
baselineDigest: string;
targetDevice: string;
targetInode: string;
targetSha256: string;
}>;
}
interface FileEvidence {
@@ -155,20 +168,73 @@ function evidence(
return Object.freeze({ ...payload, evidenceDigest: cutoverDigest(payload) });
}
function manualReviewEvidence(): Readonly<TargetDataReconciliationEvidence> {
return evidence({
disposition: 'manual_review',
targetMatchesActivation: null,
sourceMatchesActivation: null,
targetSidecarsClear: null,
sourceSidecarsClear: null,
targetFileIdentityDigest: UNKNOWN_DIGEST,
sourceFileIdentityDigest: UNKNOWN_DIGEST,
});
}
export function readTargetDataReconciliationEvidence(
command: Readonly<LocalDeploymentTargetRunCommand>,
uid: number,
): Readonly<TargetDataReconciliationEvidence> {
return readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
activationPath: command.request.activationPath,
legacySourcePath: command.request.legacySourcePath,
targetDatabasePath: command.request.targetDatabasePath,
expectedActivationDigest: command.request.expectedActivationDigest,
},
uid,
);
try {
const application = readTargetApplicationBinding(command);
let adoptedTargetBaseline:
| TargetDataReconciliationInput['adoptedTargetBaseline']
| undefined;
if (application.schema === 'qinglong/local-application-process@v4') {
const baseline = readAdoptedTargetBaseline(
adoptedTargetBaselinePath(command.options.deploymentRoot),
);
if (
baseline.preparedAtMs > command.request.requestedAtMs ||
baseline.profile !== command.request.profile ||
baseline.instanceId !== command.request.instanceId ||
baseline.cutoverId !== command.request.cutoverId ||
baseline.activationDigest !==
command.request.expectedActivationDigest ||
baseline.commitmentDigest !==
command.request.expectedLegacyCommitmentDigest ||
baseline.applicationConfigDigest !== application.configDigest ||
baseline.legacyDataApplicationCommitDigest !==
application.legacyDataApplicationCommitDigest ||
baseline.legacyDataApplicationReceiptDigest !==
application.legacyDataApplicationReceiptDigest ||
baseline.targetPathDigest !==
textDigest(command.request.targetDatabasePath)
) {
throw new Error('adopted target baseline binding drifted');
}
adoptedTargetBaseline = Object.freeze({
baselineDigest: baseline.baselineDigest,
targetDevice: baseline.targetDevice,
targetInode: baseline.targetInode,
targetSha256: baseline.targetSha256,
});
}
return readTargetDataReconciliationEvidenceForPaths(
{
profile: command.request.profile,
activationPath: command.request.activationPath,
legacySourcePath: command.request.legacySourcePath,
targetDatabasePath: command.request.targetDatabasePath,
expectedActivationDigest: command.request.expectedActivationDigest,
...(adoptedTargetBaseline === undefined
? {}
: { adoptedTargetBaseline }),
},
uid,
);
} catch {
return manualReviewEvidence();
}
}
export function readTargetDataReconciliationEvidenceForPaths(
@@ -215,19 +281,26 @@ export function readTargetDataReconciliationEvidenceForPaths(
if (
target.pathDigest !== activation.targetPathDigest ||
target.device !== activation.targetDevice ||
target.inode !== activation.targetInode
target.inode !== activation.targetInode ||
(input.adoptedTargetBaseline !== undefined &&
(target.device !== input.adoptedTargetBaseline.targetDevice ||
target.inode !== input.adoptedTargetBaseline.targetInode))
) {
throw new Error('target database stable identity drifted');
}
const targetMatchesActivation = target.sha256 === activation.targetSha256;
const targetMatchesBaseline =
input.adoptedTargetBaseline === undefined
? targetMatchesActivation
: target.sha256 === input.adoptedTargetBaseline.targetSha256;
const sourceMatchesActivation = source.sha256 === activation.sourceSha256;
const disposition =
!targetMatchesActivation || !target.sidecarsClear
!targetMatchesBaseline || !target.sidecarsClear
? ('reconciliation_required' as const)
: sourceMatchesActivation && source.sidecarsClear
? ('rollback_candidate' as const)
: ('manual_review' as const);
return evidence({
const reconciliationPayload = {
disposition,
targetMatchesActivation,
sourceMatchesActivation,
@@ -235,17 +308,19 @@ export function readTargetDataReconciliationEvidenceForPaths(
sourceSidecarsClear: source.sidecarsClear,
targetFileIdentityDigest: target.identityDigest,
sourceFileIdentityDigest: source.identityDigest,
});
};
return evidence(
input.adoptedTargetBaseline === undefined
? reconciliationPayload
: {
...reconciliationPayload,
baselineKind: 'adopted_target' as const,
baselineDigest: input.adoptedTargetBaseline.baselineDigest,
targetMatchesBaseline,
},
);
} catch {
return evidence({
disposition: 'manual_review',
targetMatchesActivation: null,
sourceMatchesActivation: null,
targetSidecarsClear: null,
sourceSidecarsClear: null,
targetFileIdentityDigest: UNKNOWN_DIGEST,
sourceFileIdentityDigest: UNKNOWN_DIGEST,
});
return manualReviewEvidence();
}
}
@@ -264,9 +339,16 @@ export function verifyTargetDataReconciliationEvidence(
'targetMatchesActivation',
'targetSidecarsClear',
].sort();
const expectedAdopted = [
...expected,
'baselineDigest',
'baselineKind',
'targetMatchesBaseline',
].sort();
const adopted = JSON.stringify(keys) === JSON.stringify(expectedAdopted);
const { evidenceDigest, ...payload } = candidate;
if (
JSON.stringify(keys) !== JSON.stringify(expected) ||
(!adopted && JSON.stringify(keys) !== JSON.stringify(expected)) ||
(candidate.disposition !== 'rollback_candidate' &&
candidate.disposition !== 'reconciliation_required' &&
candidate.disposition !== 'manual_review') ||
@@ -278,6 +360,20 @@ export function verifyTargetDataReconciliationEvidence(
typeof candidate.targetSidecarsClear !== 'boolean') ||
(candidate.sourceSidecarsClear !== null &&
typeof candidate.sourceSidecarsClear !== 'boolean') ||
(adopted &&
(candidate.baselineKind !== 'adopted_target' ||
typeof candidate.baselineDigest !== 'string' ||
!DIGEST_PATTERN.test(candidate.baselineDigest) ||
(candidate.targetMatchesBaseline !== null &&
typeof candidate.targetMatchesBaseline !== 'boolean') ||
(candidate.disposition === 'rollback_candidate' &&
(candidate.targetMatchesBaseline !== true ||
candidate.targetSidecarsClear !== true ||
candidate.sourceMatchesActivation !== true ||
candidate.sourceSidecarsClear !== true)) ||
(candidate.disposition === 'reconciliation_required' &&
candidate.targetMatchesBaseline !== false &&
candidate.targetSidecarsClear !== false))) ||
typeof candidate.targetFileIdentityDigest !== 'string' ||
!DIGEST_PATTERN.test(candidate.targetFileIdentityDigest) ||
typeof candidate.sourceFileIdentityDigest !== 'string' ||
@@ -20,12 +20,17 @@ export interface LegacySilenceEvidence {
}
export interface TargetApplicationBinding {
readonly schema:
| 'qinglong/local-application-process@v3'
| 'qinglong/local-application-process@v4';
readonly configDigest: string;
readonly targetActivationPath: string;
readonly targetLegacySourcePath: string;
readonly targetDatabasePath: string;
readonly targetRecoveryPath: string;
readonly targetManifestPath: string;
readonly legacyDataApplicationCommitDigest: string | null;
readonly legacyDataApplicationReceiptDigest: string | null;
}
export interface TargetContainerEvidence {
@@ -253,8 +258,22 @@ export function readTargetApplicationBinding(
);
const storage = object(config.storage, 'target storage configuration');
const cutover = object(config.cutover, 'target cutover configuration');
const isV4 = config.schema === 'qinglong/local-application-process@v4';
const legacyDataApplication = isV4
? object(
config.legacyDataApplication,
'target legacy data application configuration',
)
: undefined;
if (legacyDataApplication !== undefined) {
exact(
legacyDataApplication,
['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'],
'target legacy data application configuration',
);
}
if (
config.schema !== 'qinglong/local-application-process@v3' ||
(config.schema !== 'qinglong/local-application-process@v3' && !isV4) ||
config.profile !== command.request.profile ||
config.instanceId !== command.request.instanceId ||
storage.mode !== 'adopted' ||
@@ -278,17 +297,35 @@ export function readTargetApplicationBinding(
cutover.cutoverId !== command.request.cutoverId ||
cutover.commitmentPath !== command.request.expectedTargetCommitmentPath ||
cutover.expectedCommitmentDigest !==
command.request.expectedLegacyCommitmentDigest
command.request.expectedLegacyCommitmentDigest ||
(legacyDataApplication !== undefined &&
(typeof legacyDataApplication.commitPath !== 'string' ||
!path.isAbsolute(legacyDataApplication.commitPath) ||
path.normalize(legacyDataApplication.commitPath) !==
legacyDataApplication.commitPath ||
typeof legacyDataApplication.expectedCommitDigest !== 'string' ||
!DIGEST_PATTERN.test(legacyDataApplication.expectedCommitDigest) ||
typeof legacyDataApplication.expectedReceiptDigest !== 'string' ||
!DIGEST_PATTERN.test(legacyDataApplication.expectedReceiptDigest)))
) {
configurationError('target application configuration binding is invalid');
}
return Object.freeze({
schema: config.schema as TargetApplicationBinding['schema'],
configDigest: cutoverDigest(config),
targetActivationPath: storage.activationPath,
targetLegacySourcePath: storage.sourcePath,
targetDatabasePath: storage.targetPath,
targetRecoveryPath: storage.recoveryPath,
targetManifestPath: storage.manifestPath,
legacyDataApplicationCommitDigest:
legacyDataApplication === undefined
? null
: (legacyDataApplication.expectedCommitDigest as string),
legacyDataApplicationReceiptDigest:
legacyDataApplication === undefined
? null
: (legacyDataApplication.expectedReceiptDigest as string),
});
}
@@ -762,11 +762,39 @@ for (const kind of ['systemd', 'openrc', 'compose', 'docker-target']) {
readOnly: true,
},
]);
const baseline = JSON.parse(
fs.readFileSync(
path.join(state.root, 'service/adopted-target-baseline.json'),
'utf8',
),
);
const { baselineDigest, ...baselinePayload } = baseline;
assert.equal(baseline.kind, 'qinglong3-local-adopted-target-baseline');
assert.equal(
baseline.applicationConfigDigest,
canonicalDigest(application),
);
assert.equal(
baseline.legacyDataApplicationCommitDigest,
state.command.request.legacyDataApplication.expectedCommitDigest,
);
assert.equal(
baseline.targetSha256,
hexDigest(fs.readFileSync(state.targetPath)),
);
assert.equal(baseline.targetSidecarsClear, true);
assert.equal(baselineDigest, canonicalDigest(baselinePayload));
assert.equal(
fs.existsSync(path.join(state.root, 'service/compose.image.yaml')),
false,
);
} else {
assert.equal(
fs.existsSync(
path.join(state.root, 'service/adopted-target-baseline.json'),
),
false,
);
assert.equal(
fs.existsSync(path.join(state.root, 'service/compose.image.yaml')),
false,
@@ -26,6 +26,10 @@ function digest(value) {
.digest('hex');
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function targetPath(state, hostPath) {
return path.join('/host', path.relative(state.managementRoot, hostPath));
}
@@ -266,6 +270,72 @@ function fixture(t) {
return state;
}
function prepareAdoptedV4Baseline(state) {
const commitDigest = 'b'.repeat(64);
const receiptDigest = 'c'.repeat(64);
const config = {
schema: 'qinglong/local-application-process@v4',
profile: 'edge',
instanceId: 'edge-router-1',
storage: {
mode: 'adopted',
sourcePath: targetPath(state, state.legacySourcePath),
targetPath: targetPath(state, state.targetDatabasePath),
recoveryPath: targetPath(state, state.recoveryPath),
manifestPath: targetPath(state, state.manifestPath),
activationPath: targetPath(state, state.activationPath),
expectedActivationDigest: state.activationDigest,
},
cutover: {
cutoverId: state.cutoverId,
commitmentPath: state.targetCommitmentPath,
expectedCommitmentDigest: state.legacyCommitmentDigest,
},
legacyDataApplication: {
commitPath: path.join(
state.deploymentRoot,
'transformation',
'commit.json',
),
expectedCommitDigest: commitDigest,
expectedReceiptDigest: receiptDigest,
},
};
fs.writeFileSync(state.applicationConfigPath, `${JSON.stringify(config)}\n`, {
mode: 0o600,
});
const target = fs.statSync(state.targetDatabasePath, { bigint: true });
const baselinePayload = {
schemaVersion: 1,
kind: 'qinglong3-local-adopted-target-baseline',
state: 'prepared',
preparedAtMs: 2_000,
profile: 'edge',
instanceId: 'edge-router-1',
cutoverId: state.cutoverId,
activationDigest: state.activationDigest,
commitmentDigest: state.legacyCommitmentDigest,
applicationConfigDigest: digest(config),
legacyDataApplicationCommitDigest: commitDigest,
legacyDataApplicationReceiptDigest: receiptDigest,
targetPathDigest: sha256(state.targetDatabasePath),
targetDevice: target.dev.toString(),
targetInode: target.ino.toString(),
targetSha256: sha256(fs.readFileSync(state.targetDatabasePath)),
targetSidecarsClear: true,
};
const baseline = {
...baselinePayload,
baselineDigest: digest(baselinePayload),
};
fs.writeFileSync(
path.join(state.deploymentRoot, 'service/adopted-target-baseline.json'),
`${JSON.stringify(baseline)}\n`,
{ mode: 0o600 },
);
return baseline;
}
function command(state, generation = 1) {
return {
schemaVersion: 1,
@@ -989,6 +1059,78 @@ test('stops an active target and proves an unchanged rollback candidate', async
);
});
test('uses the authenticated v4 post-apply baseline for clean rollback', async (t) => {
const state = fixture(t);
fs.writeFileSync(state.targetDatabasePath, 'authenticated applied target\n');
const baseline = prepareAdoptedV4Baseline(state);
await runLocalDeploymentDockerTarget(command(state), harness(state));
const stopped = stopLocalDeploymentDockerTarget(
stopCommand(state),
harness(state),
);
assert.equal(stopped.state, 'target_stopped');
assert.equal(stopped.reconciliation, 'rollback_candidate');
const outcome = JSON.parse(
fs.readFileSync(
path.join(state.journal, '0006-target-stop-outcome.json'),
'utf8',
),
);
const reconciliation = outcome.evidence.reconciliation;
assert.equal(reconciliation.baselineKind, 'adopted_target');
assert.equal(reconciliation.baselineDigest, baseline.baselineDigest);
assert.equal(reconciliation.targetMatchesActivation, false);
assert.equal(reconciliation.targetMatchesBaseline, true);
assert.equal(reconciliation.sourceMatchesActivation, true);
});
test('requires reconciliation when the v4 target writes after its baseline', async (t) => {
const state = fixture(t);
fs.writeFileSync(state.targetDatabasePath, 'authenticated applied target\n');
const baseline = prepareAdoptedV4Baseline(state);
await runLocalDeploymentDockerTarget(command(state), harness(state));
fs.writeFileSync(state.targetDatabasePath, 'post-cutover write\n');
const stopped = stopLocalDeploymentDockerTarget(
stopCommand(state),
harness(state),
);
assert.equal(stopped.reconciliation, 'reconciliation_required');
const outcome = JSON.parse(
fs.readFileSync(
path.join(state.journal, '0006-target-stop-outcome.json'),
'utf8',
),
);
assert.equal(
outcome.evidence.reconciliation.baselineDigest,
baseline.baselineDigest,
);
assert.equal(outcome.evidence.reconciliation.targetMatchesBaseline, false);
});
test('fails a drifted v4 target baseline closed to manual review', async (t) => {
const state = fixture(t);
fs.writeFileSync(state.targetDatabasePath, 'authenticated applied target\n');
prepareAdoptedV4Baseline(state);
await runLocalDeploymentDockerTarget(command(state), harness(state));
const baselinePath = path.join(
state.deploymentRoot,
'service/adopted-target-baseline.json',
);
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
fs.writeFileSync(
baselinePath,
`${JSON.stringify({ ...baseline, targetSha256: 'd'.repeat(64) })}\n`,
{ mode: 0o600 },
);
const stopped = stopLocalDeploymentDockerTarget(
stopCommand(state),
harness(state),
);
assert.equal(stopped.state, 'target_stopped');
assert.equal(stopped.reconciliation, 'manual_review');
});
test('prepares and commits an exact legacy rollback without mutating target data', async (t) => {
const state = fixture(t);
await runLocalDeploymentDockerTarget(command(state), harness(state));