Files
qinglong/packages/ql3-local-owner-cli/src/deployment/reconciliation/lineageProof.ts
T

476 lines
16 KiB
TypeScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import { cutoverDigest } from '../cutover/targetEvidence';
import type { LocalReconciliationCapturePrepareCommand } from './contract';
const MAX_LINEAGE_FILE_BYTES = 64 * 1024;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
export interface LocalReconciliationLineageProjection {
readonly applicationConfigDigest: string;
readonly activationDigest: string;
readonly adoptionManifestDigest: string;
readonly commitmentDigest: string;
readonly legacyDataApplicationCommitDigest: string;
readonly legacyDataApplicationReceiptDigest: string;
readonly adoptedBundleDigest: string;
readonly sourceSha256: string;
readonly targetSha256: string;
readonly recoverySha256: string;
readonly projectionDigest: string;
}
interface PrivateJsonMaterial {
readonly value: unknown;
readonly sha256: string;
}
interface LocalDataApplicationEvidence {
readonly profile: 'edge' | 'standalone';
readonly commitDigest: string;
readonly receiptDigest: string;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
configurationError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(
value: Record<string, unknown>,
keys: readonly string[],
label: 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(`${label} shape is invalid`);
}
}
function sameStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.uid === right.uid &&
left.gid === right.gid &&
left.mode === right.mode &&
left.nlink === right.nlink &&
left.size === right.size &&
left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs
);
}
function privateJsonMaterial(
filePath: string,
uid: number,
label: string,
): Readonly<PrivateJsonMaterial> {
let descriptor: number | undefined;
let bytes: Buffer | undefined;
try {
const pathStat = fs.lstatSync(filePath, { bigint: true });
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
if (
!pathStat.isFile() ||
pathStat.isSymbolicLink() ||
!sameStat(pathStat, opened) ||
opened.uid !== BigInt(uid) ||
opened.nlink !== 1n ||
(opened.mode & 0o077n) !== 0n ||
opened.size < 2n ||
opened.size > BigInt(MAX_LINEAGE_FILE_BYTES) ||
fs.realpathSync(filePath) !== filePath
) {
configurationError(`${label} identity is invalid`);
}
bytes = Buffer.allocUnsafe(Number(opened.size));
let offset = 0;
while (offset < bytes.byteLength) {
const count = fs.readSync(
descriptor,
bytes,
offset,
bytes.byteLength - offset,
null,
);
if (count === 0) break;
offset += count;
}
const after = fs.fstatSync(descriptor, { bigint: true });
if (offset !== bytes.byteLength || !sameStat(opened, after)) {
configurationError(`${label} changed while reading`);
}
let value: unknown;
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
) as unknown;
} catch (error) {
configurationError(`${label} is not valid UTF-8 JSON`, error);
}
return Object.freeze({
value,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
});
} catch (error) {
if (error instanceof LocalDeploymentConfigurationError) throw error;
return configurationError(`${label} cannot be read`, error);
} finally {
bytes?.fill(0);
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function documentDigest(
value: unknown,
digestKey: string,
label: string,
): Readonly<{ document: Record<string, unknown>; digest: string }> {
const document = object(value, label);
const claimed = document[digestKey];
const payload = { ...document };
delete payload[digestKey];
if (
typeof claimed !== 'string' ||
!DIGEST_PATTERN.test(claimed) ||
cutoverDigest(payload) !== claimed
) {
configurationError(`${label} digest drifted`);
}
return Object.freeze({ document, digest: claimed });
}
function textDigest(value: string): string {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function normalizeLocalDataApplicationEvidence(
value: unknown,
): Readonly<LocalDataApplicationEvidence> {
const commit = object(value, 'legacy data application commitment');
exact(
commit,
[
'commitDigest',
'committedAtMs',
'environmentSecretCount',
'kind',
'modelDigest',
'mutationId',
'profile',
'projectIdDigest',
'publicationDigest',
'receiptDigest',
'reclamation',
'schemaVersion',
'secretCount',
'sourceStageManifestDigest',
'sshSecretCount',
'state',
'transformationDigest',
],
'legacy data application commitment',
);
const reclamation = object(
commit.reclamation,
'legacy data application reclamation',
);
exact(
reclamation,
['modelRemoved', 'physicalErasureGuaranteed', 'plaintextFilesRemoved'],
'legacy data application reclamation',
);
const digests = [
commit.projectIdDigest,
commit.sourceStageManifestDigest,
commit.transformationDigest,
commit.modelDigest,
commit.publicationDigest,
commit.receiptDigest,
];
if (
commit.schemaVersion !== 1 ||
commit.kind !== 'qinglong3-legacy-data-directory-application' ||
commit.state !== 'committed' ||
typeof commit.mutationId !== 'string' ||
!UUID_V4_PATTERN.test(commit.mutationId) ||
(commit.profile !== 'edge' && commit.profile !== 'standalone') ||
digests.some(
(candidate) =>
typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate),
) ||
!Number.isSafeInteger(commit.secretCount) ||
(commit.secretCount as number) < 0 ||
!Number.isSafeInteger(commit.environmentSecretCount) ||
(commit.environmentSecretCount as number) < 0 ||
!Number.isSafeInteger(commit.sshSecretCount) ||
(commit.sshSecretCount as number) < 0 ||
(commit.secretCount as number) !==
(commit.environmentSecretCount as number) +
(commit.sshSecretCount as number) ||
!Number.isSafeInteger(commit.committedAtMs) ||
(commit.committedAtMs as number) < 0 ||
reclamation.modelRemoved !== true ||
reclamation.plaintextFilesRemoved !== true ||
reclamation.physicalErasureGuaranteed !== false
) {
configurationError('legacy data application commitment values are invalid');
}
const payload = {
schemaVersion: 1,
kind: 'qinglong3-legacy-data-directory-application',
state: 'committed',
mutationId: commit.mutationId,
profile: commit.profile,
projectIdDigest: commit.projectIdDigest,
sourceStageManifestDigest: commit.sourceStageManifestDigest,
transformationDigest: commit.transformationDigest,
modelDigest: commit.modelDigest,
publicationDigest: commit.publicationDigest,
receiptDigest: commit.receiptDigest,
secretCount: commit.secretCount,
environmentSecretCount: commit.environmentSecretCount,
sshSecretCount: commit.sshSecretCount,
committedAtMs: commit.committedAtMs,
reclamation: {
modelRemoved: true,
plaintextFilesRemoved: true,
physicalErasureGuaranteed: false,
},
};
if (
typeof commit.commitDigest !== 'string' ||
!DIGEST_PATTERN.test(commit.commitDigest) ||
textDigest(JSON.stringify(payload)) !== commit.commitDigest
) {
configurationError('legacy data application commitment digest drifted');
}
return Object.freeze({
profile: commit.profile,
commitDigest: commit.commitDigest,
receiptDigest: commit.receiptDigest as string,
});
}
export function proveLocalReconciliationLineage(
command: Readonly<LocalReconciliationCapturePrepareCommand>,
uid: number,
): Readonly<LocalReconciliationLineageProjection> {
if (
command.request.applicationConfigPath !==
path.join(command.options.deploymentRoot, 'local-application.json')
) {
configurationError('application configuration path is not authoritative');
}
const applicationMaterial = privateJsonMaterial(
command.request.applicationConfigPath,
uid,
'application configuration',
);
const application = object(
applicationMaterial.value,
'application configuration',
);
const storage = object(application.storage, 'application storage');
const cutover = object(application.cutover, 'application cutover');
const dataApplication = object(
application.legacyDataApplication,
'legacy data application',
);
exact(
dataApplication,
['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'],
'legacy data application',
);
const commitmentPath = path.join(
command.options.deploymentRoot,
'service',
'cutovers',
command.request.cutoverId,
'0002-legacy-stopped.json',
);
if (
application.schema !== 'qinglong/local-application-process@v4' ||
application.profile !== command.request.profile ||
application.instanceId !== command.request.instanceId ||
storage.mode !== 'adopted' ||
storage.sourcePath !== command.request.legacySourcePath ||
storage.targetPath !== command.request.targetDatabasePath ||
storage.recoveryPath !== command.request.recoveryPath ||
storage.activationPath !== command.request.activationPath ||
storage.expectedActivationDigest !==
command.request.expectedActivationDigest ||
cutover.cutoverId !== command.request.cutoverId ||
cutover.commitmentPath !== commitmentPath ||
typeof cutover.expectedCommitmentDigest !== 'string' ||
!DIGEST_PATTERN.test(cutover.expectedCommitmentDigest) ||
typeof storage.manifestPath !== 'string' ||
!path.isAbsolute(storage.manifestPath) ||
path.normalize(storage.manifestPath) !== storage.manifestPath ||
typeof dataApplication.commitPath !== 'string' ||
!path.isAbsolute(dataApplication.commitPath) ||
path.normalize(dataApplication.commitPath) !== dataApplication.commitPath ||
typeof dataApplication.expectedCommitDigest !== 'string' ||
!DIGEST_PATTERN.test(dataApplication.expectedCommitDigest) ||
typeof dataApplication.expectedReceiptDigest !== 'string' ||
!DIGEST_PATTERN.test(dataApplication.expectedReceiptDigest)
) {
configurationError('application lineage binding drifted');
}
const activation = documentDigest(
privateJsonMaterial(command.request.activationPath, uid, 'activation')
.value,
'activationDigest',
'activation',
);
if (
activation.document.schemaVersion !== 1 ||
activation.document.kind !== 'qinglong3-local-sqlite-activation' ||
activation.document.state !== 'prepared' ||
activation.document.profile !== command.request.profile ||
activation.digest !== command.request.expectedActivationDigest ||
activation.document.sourcePathDigest !==
textDigest(command.request.legacySourcePath) ||
activation.document.targetPathDigest !==
textDigest(command.request.targetDatabasePath) ||
typeof activation.document.adoptionManifestDigest !== 'string' ||
!DIGEST_PATTERN.test(activation.document.adoptionManifestDigest) ||
typeof activation.document.sourceSha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.document.sourceSha256) ||
typeof activation.document.targetSha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.document.targetSha256) ||
typeof activation.document.recoverySha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.document.recoverySha256)
) {
configurationError('activation lineage drifted');
}
const manifest = documentDigest(
privateJsonMaterial(
storage.manifestPath as string,
uid,
'adoption manifest',
).value,
'manifestDigest',
'adoption manifest',
);
if (manifest.digest !== activation.document.adoptionManifestDigest) {
configurationError('adoption manifest lineage drifted');
}
const commitment = documentDigest(
privateJsonMaterial(commitmentPath, uid, 'legacy silence commitment').value,
'commitmentDigest',
'legacy silence commitment',
);
if (
commitment.document.schemaVersion !== 1 ||
commitment.document.kind !== 'qinglong3-local-legacy-silence-commitment' ||
commitment.document.state !== 'legacy_stopped' ||
commitment.document.cutoverId !== command.request.cutoverId ||
commitment.document.profile !== command.request.profile ||
commitment.document.instanceId !== command.request.instanceId ||
commitment.document.activationDigest !== activation.digest ||
commitment.digest !== cutover.expectedCommitmentDigest
) {
configurationError('legacy silence lineage drifted');
}
const dataCommit = normalizeLocalDataApplicationEvidence(
privateJsonMaterial(
dataApplication.commitPath as string,
uid,
'legacy data application commitment',
).value,
);
if (
dataCommit.profile !== command.request.profile ||
dataCommit.commitDigest !== dataApplication.expectedCommitDigest ||
dataCommit.receiptDigest !== dataApplication.expectedReceiptDigest
) {
configurationError('legacy data application receipt drifted');
}
const adoptedBundle = documentDigest(
privateJsonMaterial(
path.join(
command.options.deploymentRoot,
'service',
'adopted-bundle.json',
),
uid,
'adopted bundle receipt',
).value,
'bundleDigest',
'adopted bundle receipt',
);
if (
adoptedBundle.document.schemaVersion !== 1 ||
adoptedBundle.document.kind !==
'qinglong3-local-adopted-deployment-bundle' ||
adoptedBundle.document.state !== 'prepared' ||
adoptedBundle.document.profile !== command.request.profile ||
adoptedBundle.document.instanceId !== command.request.instanceId ||
adoptedBundle.document.cutoverId !== command.request.cutoverId ||
adoptedBundle.document.applicationConfigDigest !==
applicationMaterial.sha256 ||
adoptedBundle.document.activationDigest !== activation.digest ||
adoptedBundle.document.commitmentDigest !== commitment.digest ||
adoptedBundle.document.legacyDataApplicationCommitDigest !==
dataCommit.commitDigest ||
adoptedBundle.document.legacyDataApplicationReceiptDigest !==
dataCommit.receiptDigest ||
adoptedBundle.document.manifestDigest !== manifest.digest ||
adoptedBundle.document.sourcePathDigest !==
textDigest(command.request.legacySourcePath) ||
adoptedBundle.document.sourceSha256 !== activation.document.sourceSha256 ||
adoptedBundle.document.recoverySha256 !== activation.document.recoverySha256
) {
configurationError('adopted bundle lineage drifted');
}
const payload = Object.freeze({
applicationConfigDigest: applicationMaterial.sha256,
activationDigest: activation.digest,
adoptionManifestDigest: manifest.digest,
commitmentDigest: commitment.digest,
legacyDataApplicationCommitDigest: dataCommit.commitDigest,
legacyDataApplicationReceiptDigest: dataCommit.receiptDigest,
adoptedBundleDigest: adoptedBundle.digest,
sourceSha256: activation.document.sourceSha256 as string,
targetSha256: activation.document.targetSha256 as string,
recoverySha256: activation.document.recoverySha256 as string,
});
return Object.freeze({
...payload,
projectionDigest: cutoverDigest(payload),
});
}