Skip to content

Commit cbdddff

Browse files
committed
fix(ql3): bind evolved reconciliation target
1 parent a8ca739 commit cbdddff

8 files changed

Lines changed: 316 additions & 34 deletions

File tree

packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,25 @@ function normalizeOptions(
223223
fail('authentication or Secret material must be below deploymentRoot');
224224
}
225225
}
226+
const targetRelative = path.relative(
227+
normalized.deploymentRoot,
228+
normalized.targetDatabasePath,
229+
);
230+
if (
231+
!targetRelative ||
232+
targetRelative.startsWith('..') ||
233+
path.isAbsolute(targetRelative)
234+
) {
235+
fail('targetDatabasePath must be below deploymentRoot');
236+
}
226237
if (
227-
roots.some(
228-
(root) =>
229-
overlaps(root, normalized.targetDatabasePath) ||
230-
overlaps(normalized.targetDatabasePath, root),
231-
)
238+
roots
239+
.slice(1)
240+
.some(
241+
(root) =>
242+
overlaps(root, normalized.targetDatabasePath) ||
243+
overlaps(normalized.targetDatabasePath, root),
244+
)
232245
) {
233246
fail('targetDatabasePath overlaps an authority root');
234247
}

packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,14 @@ export async function applyLocalReconciliationSecretConfig(
338338
) {
339339
fail('apply command is detached from a ready signed decision');
340340
}
341+
const targetSnapshotSha256 = terminal.context.planHeader.targetSnapshotSha256;
342+
if (terminal.receipt.applyBindingCount > 0 && targetSnapshotSha256 === null) {
343+
fail('active binding plan is missing evolved target authority');
344+
}
345+
const stoppedProofOptions =
346+
targetSnapshotSha256 === null
347+
? {}
348+
: { expectedEvolvedTargetSha256: targetSnapshotSha256 };
341349
const planTerminal = terminal.context.planTerminal;
342350
const capture = readLocalReconciliationCaptureIntent(
343351
planTerminal.intent.command.options.captureRoot,
@@ -375,7 +383,11 @@ export async function applyLocalReconciliationSecretConfig(
375383
fail('apply lost reviewed head compare-and-swap');
376384
}
377385
discardUnpreparedLocalReconciliationSecretConfigMaterials(selected);
378-
const before = proveLocalReconciliationStoppedState(capture.command, uid);
386+
const before = proveLocalReconciliationStoppedState(
387+
capture.command,
388+
uid,
389+
stoppedProofOptions,
390+
);
379391
const materials: Readonly<PreparedReconciliationSecretConfigMaterial>[] =
380392
[];
381393
const openRequirements =
@@ -441,7 +453,11 @@ export async function applyLocalReconciliationSecretConfig(
441453
? {}
442454
: { busyTimeoutMs: command.options.busyTimeoutMs }),
443455
});
444-
const after = proveLocalReconciliationStoppedState(capture.command, uid);
456+
const after = proveLocalReconciliationStoppedState(
457+
capture.command,
458+
uid,
459+
stoppedProofOptions,
460+
);
445461
if (after.proofDigest !== before.proofDigest) {
446462
fail('stopped target drifted across preparation');
447463
}
@@ -522,7 +538,11 @@ export async function applyLocalReconciliationSecretConfig(
522538
fail('apply lost prepared head compare-and-swap');
523539
}
524540
if (!recoveringPreparedIntent) {
525-
const stopped = proveLocalReconciliationStoppedState(capture.command, uid);
541+
const stopped = proveLocalReconciliationStoppedState(
542+
capture.command,
543+
uid,
544+
stoppedProofOptions,
545+
);
526546
if (stopped.proofDigest !== intent.stoppedProofDigest) {
527547
fail('stopped proof drifted before write');
528548
}

packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ function publishPlan(
614614
authority: Readonly<SecretConfigReviewAuthority>,
615615
dependencies: LocalReconciliationSecretConfigPlanDependencies,
616616
uid: number,
617+
targetSnapshotSha256: string | null,
617618
automationTarget?: DatabaseSync,
618619
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
619620
let descriptor: number | undefined;
@@ -649,6 +650,7 @@ function publishPlan(
649650
projectId: command.request.projectId,
650651
tableDisposition: authority.tableDisposition,
651652
unadaptedLegacyConfigCount: authority.unadaptedLegacyConfigCount,
653+
targetSnapshotSha256,
652654
preparedHeadDigest: head.headDigest,
653655
preparedAtMs: command.request.preparedAtMs,
654656
});
@@ -987,6 +989,7 @@ export async function planLocalReconciliationSecretConfig(
987989
authority,
988990
dependencies,
989991
identity.uid,
992+
automationTarget?.snapshotSha256 ?? null,
990993
target,
991994
);
992995
const receipt =

packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ function header(
226226
'schemaVersion',
227227
'secretConfigId',
228228
'tableDisposition',
229+
'targetSnapshotSha256',
229230
'unadaptedLegacyConfigCount',
230231
],
231232
'header',
@@ -244,6 +245,9 @@ function header(
244245
record.projectId.length < 1 ||
245246
(record.tableDisposition !== 'absent' &&
246247
record.tableDisposition !== 'manual_external') ||
248+
(record.targetSnapshotSha256 !== null &&
249+
(typeof record.targetSnapshotSha256 !== 'string' ||
250+
!DIGEST_PATTERN.test(record.targetSnapshotSha256))) ||
247251
!Number.isSafeInteger(record.unadaptedLegacyConfigCount) ||
248252
(record.unadaptedLegacyConfigCount as number) < 0 ||
249253
![

packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface LocalReconciliationSecretConfigPlanHeader {
5454
readonly projectId: string;
5555
readonly tableDisposition: 'absent' | 'manual_external';
5656
readonly unadaptedLegacyConfigCount: number;
57+
readonly targetSnapshotSha256: string | null;
5758
readonly preparedHeadDigest: string;
5859
readonly preparedAtMs: number;
5960
readonly headerDigest: string;
@@ -350,11 +351,7 @@ function targetAutomationAdoptionProjection(
350351
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
351352
),
352353
planDigest: adoptionText(row, 'planDigest', DIGEST_PATTERN),
353-
inventoryDigest: adoptionText(
354-
row,
355-
'inventoryDigest',
356-
DIGEST_PATTERN,
357-
),
354+
inventoryDigest: adoptionText(row, 'inventoryDigest', DIGEST_PATTERN),
358355
decisionDigest: adoptionText(row, 'decisionDigest', DIGEST_PATTERN),
359356
receiptDigest: adoptionText(row, 'receiptDigest', DIGEST_PATTERN),
360357
authorizationFileDigest: adoptionText(
@@ -372,11 +369,7 @@ function targetAutomationAdoptionProjection(
372369
adoptedTriggerCount: selectedAdoptedTriggerCount,
373370
skippedCount,
374371
auditEventId: adoptionText(row, 'auditEventId', UUID_V4_PATTERN),
375-
createdAtMs: adoptionCount(
376-
row,
377-
'createdAtMs',
378-
Number.MAX_SAFE_INTEGER,
379-
),
372+
createdAtMs: adoptionCount(row, 'createdAtMs', Number.MAX_SAFE_INTEGER),
380373
});
381374
if (payload.auditEventId !== payload.mutationId) {
382375
fail('target Automation adoption audit binding drifted');
@@ -1048,8 +1041,7 @@ export function buildLocalReconciliationSecretConfigPlanReceipt(
10481041
adoptedLegacyTriggerCount: footer.adoptedLegacyTriggerCount,
10491042
adoptionProvenanceTaskCount: footer.adoptionProvenanceTaskCount,
10501043
adoptionProvenanceTriggerCount: footer.adoptionProvenanceTriggerCount,
1051-
automationAdoptionProvenanceState:
1052-
footer.automationAdoptionProvenanceState,
1044+
automationAdoptionProvenanceState: footer.automationAdoptionProvenanceState,
10531045
unadaptedLegacyConfigCount: footer.unadaptedLegacyConfigCount,
10541046
outcome: footer.outcome,
10551047
preparedAtMs: header.preparedAtMs,

packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -342,14 +342,25 @@ function normalizeOptions(
342342
}
343343
if (
344344
secretConfig !== null &&
345-
roots.some(
346-
(root) =>
347-
overlaps(root, secretConfig.targetDatabasePath) ||
348-
overlaps(secretConfig.targetDatabasePath, root),
349-
)
345+
roots
346+
.slice(1)
347+
.some(
348+
(root) =>
349+
overlaps(root, secretConfig.targetDatabasePath) ||
350+
overlaps(secretConfig.targetDatabasePath, root),
351+
)
350352
) {
351353
fail('targetDatabasePath overlaps an authority root');
352354
}
355+
if (secretConfig !== null) {
356+
const relative = path.relative(
357+
normalized.deploymentRoot,
358+
secretConfig.targetDatabasePath,
359+
);
360+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
361+
fail('Secret/Config targetDatabasePath must be below deploymentRoot');
362+
}
363+
}
353364
if (
354365
automation !== null &&
355366
secretConfig !== null &&

packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@ const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
2828
export interface LocalReconciliationStoppedProof {
2929
readonly stoppedRecordDigest: string;
3030
readonly reconciliationEvidenceDigest: string;
31+
readonly evolvedTargetSha256?: string;
3132
readonly proofDigest: string;
3233
}
3334

35+
export interface LocalReconciliationStoppedProofOptions {
36+
readonly expectedEvolvedTargetSha256?: string;
37+
}
38+
3439
function configurationError(message: string): never {
3540
throw new LocalDeploymentConfigurationError(message);
3641
}
@@ -63,6 +68,61 @@ function exact(
6368
}
6469
}
6570

71+
function sameFileStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
72+
return (
73+
left.dev === right.dev &&
74+
left.ino === right.ino &&
75+
left.mode === right.mode &&
76+
left.nlink === right.nlink &&
77+
left.uid === right.uid &&
78+
left.size === right.size &&
79+
left.mtimeNs === right.mtimeNs &&
80+
left.ctimeNs === right.ctimeNs
81+
);
82+
}
83+
84+
function stableTargetSha256(filePath: string, uid: number): string {
85+
let descriptor: number | undefined;
86+
const buffer = Buffer.allocUnsafe(64 * 1024);
87+
try {
88+
const pathStat = fs.lstatSync(filePath, { bigint: true });
89+
descriptor = fs.openSync(
90+
filePath,
91+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
92+
);
93+
const before = fs.fstatSync(descriptor, { bigint: true });
94+
if (
95+
!pathStat.isFile() ||
96+
pathStat.isSymbolicLink() ||
97+
!sameFileStat(pathStat, before) ||
98+
before.uid !== BigInt(uid) ||
99+
before.nlink !== 1n ||
100+
(before.mode & 0o077n) !== 0n ||
101+
fs.realpathSync(filePath) !== filePath ||
102+
before.size < 1n
103+
) {
104+
configurationError('evolved target database identity is invalid');
105+
}
106+
const hash = crypto.createHash('sha256');
107+
for (;;) {
108+
const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
109+
if (count === 0) break;
110+
hash.update(buffer.subarray(0, count));
111+
}
112+
const after = fs.fstatSync(descriptor, { bigint: true });
113+
if (!sameFileStat(before, after)) {
114+
configurationError('evolved target database changed while hashing');
115+
}
116+
return hash.digest('hex');
117+
} catch (error) {
118+
if (error instanceof LocalDeploymentConfigurationError) throw error;
119+
return configurationError('evolved target database is unavailable');
120+
} finally {
121+
buffer.fill(0);
122+
if (descriptor !== undefined) fs.closeSync(descriptor);
123+
}
124+
}
125+
66126
function serviceManagerStoppedPath(
67127
command: Readonly<LocalReconciliationCapturePrepareCommand>,
68128
): string {
@@ -177,7 +237,15 @@ function serviceManagerStoppedRecord(
177237
export function proveLocalReconciliationStoppedState(
178238
command: Readonly<LocalReconciliationCapturePrepareCommand>,
179239
uid: number,
240+
options: Readonly<LocalReconciliationStoppedProofOptions> = {},
180241
): Readonly<LocalReconciliationStoppedProof> {
242+
const expectedEvolvedTargetSha256 = options.expectedEvolvedTargetSha256;
243+
if (
244+
expectedEvolvedTargetSha256 !== undefined &&
245+
!DIGEST_PATTERN.test(expectedEvolvedTargetSha256)
246+
) {
247+
configurationError('evolved target snapshot digest is invalid');
248+
}
181249
const persisted =
182250
command.request.stoppedAuthority === 'docker'
183251
? dockerStoppedEvidence(command)
@@ -198,11 +266,42 @@ export function proveLocalReconciliationStoppedState(
198266
},
199267
uid,
200268
);
201-
if (
202-
current.disposition !== 'reconciliation_required' ||
203-
(persisted !== undefined &&
204-
persisted.evidenceDigest !== current.evidenceDigest)
205-
) {
269+
const exactStoppedData =
270+
expectedEvolvedTargetSha256 === undefined &&
271+
current.disposition === 'reconciliation_required' &&
272+
(persisted === undefined ||
273+
persisted.evidenceDigest === current.evidenceDigest);
274+
let evolvedStoppedData = false;
275+
if (expectedEvolvedTargetSha256 !== undefined) {
276+
const currentSha256 = stableTargetSha256(
277+
command.request.targetDatabasePath,
278+
uid,
279+
);
280+
const confirmed = readTargetDataReconciliationEvidenceForPaths(
281+
{
282+
profile: command.request.profile,
283+
activationPath: command.request.activationPath,
284+
legacySourcePath: command.request.legacySourcePath,
285+
targetDatabasePath: command.request.targetDatabasePath,
286+
expectedActivationDigest: command.request.expectedActivationDigest,
287+
...(adoptedTargetBaseline === undefined
288+
? {}
289+
: { adoptedTargetBaseline }),
290+
},
291+
uid,
292+
);
293+
evolvedStoppedData =
294+
current.disposition === 'reconciliation_required' &&
295+
current.sourceMatchesActivation === true &&
296+
current.sourceSidecarsClear === true &&
297+
current.targetSidecarsClear === true &&
298+
current.targetMatchesActivation === false &&
299+
(current.baselineKind !== 'adopted_target' ||
300+
current.targetMatchesBaseline === false) &&
301+
currentSha256 === expectedEvolvedTargetSha256 &&
302+
confirmed.evidenceDigest === current.evidenceDigest;
303+
}
304+
if (!exactStoppedData && !evolvedStoppedData) {
206305
configurationError(
207306
'stopped data does not have exact reconciliation-required evidence',
208307
);
@@ -211,6 +310,9 @@ export function proveLocalReconciliationStoppedState(
211310
stoppedAuthority: command.request.stoppedAuthority,
212311
stoppedRecordDigest: command.request.expectedStoppedRecordDigest,
213312
reconciliationEvidenceDigest: current.evidenceDigest,
313+
...(expectedEvolvedTargetSha256 === undefined
314+
? {}
315+
: { evolvedTargetSha256: expectedEvolvedTargetSha256 }),
214316
});
215317
return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) });
216318
}

0 commit comments

Comments
 (0)