fix: unify release runtime coherence verification
This commit is contained in:
+110
-70
@@ -1,4 +1,5 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { shouldRetry } from "../src/adapters/http/retry-policy.ts";
|
||||
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts";
|
||||
@@ -6,8 +7,17 @@ import { verifyCompatibilityTuple } from "../src/application/policies/compatibil
|
||||
import type { StoragePort } from "../src/application/ports/storage-port.ts";
|
||||
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
|
||||
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
|
||||
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.ts";
|
||||
import {
|
||||
parseReleaseArtifact,
|
||||
parseRuntimeConfigArtifact,
|
||||
type ReleaseArtifact,
|
||||
} from "../src/contracts/release-artifacts.ts";
|
||||
import { projectTelemetryEvent } from "../src/contracts/telemetry.ts";
|
||||
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
|
||||
import {
|
||||
verifyReleaseRuntimeCoherence,
|
||||
type ReleaseRuntimeCoherenceInput,
|
||||
} from "./lib/release-runtime-coherence.ts";
|
||||
|
||||
type RecoveryAssertion = Readonly<{
|
||||
assertion: string;
|
||||
@@ -38,35 +48,15 @@ type RunbookDocument = Readonly<{
|
||||
runbooks: Record<string, RunbookSpecification>;
|
||||
}>;
|
||||
|
||||
type ReleaseManifest = Record<string, unknown> & {
|
||||
buildId: string;
|
||||
configSchemaVersion: string;
|
||||
apiContractVersion: string;
|
||||
assetManifestHash: string;
|
||||
releaseId: string;
|
||||
};
|
||||
|
||||
const runbookId = process.argv
|
||||
.slice(2)
|
||||
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
|
||||
const document = JSON.parse(
|
||||
await readFile("config/runbooks/runbooks.json", "utf8"),
|
||||
) as RunbookDocument;
|
||||
const specification = runbookId ? document.runbooks[runbookId] : undefined;
|
||||
if (!runbookId || !specification) {
|
||||
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
async function releaseManifest(): Promise<ReleaseManifest> {
|
||||
async function releaseManifest(): Promise<ReleaseArtifact> {
|
||||
for (const candidate of [
|
||||
"dist/release-manifest.json",
|
||||
"public/release-manifest.json",
|
||||
]) {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(candidate, "utf8"),
|
||||
) as ReleaseManifest;
|
||||
return parseReleaseArtifact(
|
||||
JSON.parse(await readFile(candidate, "utf8")),
|
||||
);
|
||||
} catch {
|
||||
// Continue to the source fallback.
|
||||
}
|
||||
@@ -238,15 +228,29 @@ async function drillTelemetry(): Promise<DrillResult> {
|
||||
|
||||
async function drillRollback(): Promise<DrillResult> {
|
||||
const release = await releaseManifest();
|
||||
const runtime = JSON.parse(
|
||||
await readFile(
|
||||
(await access("dist/config.json").then(() => true).catch(() => false))
|
||||
? "dist/config.json"
|
||||
: "public/config.json",
|
||||
"utf8",
|
||||
const runtimeArtifact = parseRuntimeConfigArtifact(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
(await access("dist/config.json").then(() => true).catch(() => false))
|
||||
? "dist/config.json"
|
||||
: "public/config.json",
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
);
|
||||
const coherent = compareReleaseToRuntime(release, runtime);
|
||||
const runtime = {
|
||||
...runtimeArtifact,
|
||||
BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"),
|
||||
RELEASE_ID: requireIdentity(
|
||||
runtimeArtifact.RELEASE_ID,
|
||||
"runtime RELEASE_ID",
|
||||
),
|
||||
};
|
||||
const coherent = await verifyRollbackReleaseCoherence({
|
||||
release,
|
||||
runtime,
|
||||
contractPackages: EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
});
|
||||
const mixed = verifyCompatibilityTuple({
|
||||
frontend: {
|
||||
buildId: "build-a",
|
||||
@@ -284,42 +288,78 @@ const drillById: Record<string, () => Promise<DrillResult>> = {
|
||||
"FE-RB-004": drillTelemetry,
|
||||
"FE-RB-005": drillRollback,
|
||||
};
|
||||
const drill = await drillById[runbookId]();
|
||||
const escalationPathAsserted = specification.escalation.length >= 2;
|
||||
const passed =
|
||||
drill.triggerAsserted &&
|
||||
drill.containmentAsserted &&
|
||||
escalationPathAsserted &&
|
||||
drill.recoveryAssertions.every((item) => item.passed) &&
|
||||
drill.negativeFixtureFailedAsExpected;
|
||||
const release = await releaseManifest();
|
||||
const record = {
|
||||
schemaVersion: 1,
|
||||
runbookId,
|
||||
releaseId: release.releaseId,
|
||||
drillTimestamp: new Date().toISOString(),
|
||||
triggerInjected: specification.triggerKinds[0],
|
||||
triggerAsserted: drill.triggerAsserted,
|
||||
containmentAsserted: drill.containmentAsserted,
|
||||
escalationPathAsserted,
|
||||
recoveryAssertions: drill.recoveryAssertions,
|
||||
negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected,
|
||||
windowObservedBucket: specification.window,
|
||||
providerVerificationRequired: drill.providerVerificationRequired,
|
||||
passed,
|
||||
};
|
||||
const artifactDirectory = `artifacts/runbooks/${runbookId}`;
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
`${artifactDirectory}/record.json`,
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
);
|
||||
if (!passed) {
|
||||
process.stderr.write(`${runbookId} drill failed.\n`);
|
||||
process.exit(1);
|
||||
|
||||
export async function verifyRollbackReleaseCoherence(
|
||||
input: ReleaseRuntimeCoherenceInput,
|
||||
) {
|
||||
return verifyReleaseRuntimeCoherence(input);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const runbookId = process.argv
|
||||
.slice(2)
|
||||
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
|
||||
const document = JSON.parse(
|
||||
await readFile("config/runbooks/runbooks.json", "utf8"),
|
||||
) as RunbookDocument;
|
||||
const specification = runbookId ? document.runbooks[runbookId] : undefined;
|
||||
if (!runbookId || !specification) {
|
||||
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const drill = await drillById[runbookId]();
|
||||
const escalationPathAsserted = specification.escalation.length >= 2;
|
||||
const passed =
|
||||
drill.triggerAsserted &&
|
||||
drill.containmentAsserted &&
|
||||
escalationPathAsserted &&
|
||||
drill.recoveryAssertions.every((item) => item.passed) &&
|
||||
drill.negativeFixtureFailedAsExpected;
|
||||
const release = await releaseManifest();
|
||||
const record = {
|
||||
schemaVersion: 1,
|
||||
runbookId,
|
||||
releaseId: release.releaseId,
|
||||
drillTimestamp: new Date().toISOString(),
|
||||
triggerInjected: specification.triggerKinds[0],
|
||||
triggerAsserted: drill.triggerAsserted,
|
||||
containmentAsserted: drill.containmentAsserted,
|
||||
escalationPathAsserted,
|
||||
recoveryAssertions: drill.recoveryAssertions,
|
||||
negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected,
|
||||
windowObservedBucket: specification.window,
|
||||
providerVerificationRequired: drill.providerVerificationRequired,
|
||||
passed,
|
||||
};
|
||||
const artifactDirectory = `artifacts/runbooks/${runbookId}`;
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
`${artifactDirectory}/record.json`,
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
);
|
||||
if (!passed) {
|
||||
process.stderr.write(`${runbookId} drill failed.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`${runbookId} drill: PASS (${specification.gateId}; provider verification ${
|
||||
drill.providerVerificationRequired ? "still required" : "not required"
|
||||
})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function requireIdentity(value: string | undefined, label: string): string {
|
||||
if (value === undefined || value.length === 0) {
|
||||
throw new TypeError(`${label} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1];
|
||||
if (
|
||||
invokedPath !== undefined &&
|
||||
import.meta.url === pathToFileURL(invokedPath).href
|
||||
) {
|
||||
await main();
|
||||
}
|
||||
process.stdout.write(
|
||||
`${runbookId} drill: PASS (${specification.gateId}; provider verification ${
|
||||
drill.providerVerificationRequired ? "still required" : "not required"
|
||||
})\n`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { isVersionCompatible } from "../../src/application/policies/compatibility.ts";
|
||||
import { verifyContractSet } from "../../src/contracts/contract-set.ts";
|
||||
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
|
||||
import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts";
|
||||
import { compareReleaseToRuntime } from "../../src/contracts/release-tokens.ts";
|
||||
|
||||
export type ReleaseRuntimeCoherenceInput = Readonly<{
|
||||
release: ReleaseArtifact;
|
||||
runtime: Readonly<{
|
||||
BUILD_ID: string;
|
||||
CONFIG_SCHEMA_VERSION: string;
|
||||
API_CONTRACT_VERSION?: string;
|
||||
RELEASE_ID: string;
|
||||
}>;
|
||||
contractPackages: readonly InstalledContractPackageIdentity[];
|
||||
}>;
|
||||
|
||||
export type ReleaseRuntimeCoherence = Readonly<{
|
||||
compatible: boolean;
|
||||
mismatches: readonly string[];
|
||||
warnings: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Verifies the runtime identity using the release schema's own contract model.
|
||||
* V1 retains the scalar compatibility policy. V2 has no scalar projection: its
|
||||
* identity is the exact compiled package tuple set and canonical set digest.
|
||||
*/
|
||||
export async function verifyReleaseRuntimeCoherence(
|
||||
input: ReleaseRuntimeCoherenceInput,
|
||||
): Promise<ReleaseRuntimeCoherence> {
|
||||
if (input.release.schemaVersion === 1) {
|
||||
if (input.runtime.API_CONTRACT_VERSION === undefined) {
|
||||
return compareWithoutContractScalar(input, ["apiContractVersion"]);
|
||||
}
|
||||
return compareReleaseToRuntime(input.release, {
|
||||
...input.runtime,
|
||||
API_CONTRACT_VERSION: input.runtime.API_CONTRACT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
const comparison = compareWithoutContractScalar(input);
|
||||
const contractSet = await verifyContractSet({
|
||||
expected: input.contractPackages,
|
||||
manifest: input.release.contractSet,
|
||||
});
|
||||
if (contractSet.ok) return comparison;
|
||||
|
||||
const mismatches = Object.freeze([
|
||||
...comparison.mismatches,
|
||||
contractSet.code,
|
||||
]);
|
||||
return Object.freeze({
|
||||
compatible: false,
|
||||
mismatches,
|
||||
warnings: comparison.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
function compareWithoutContractScalar(
|
||||
input: Pick<ReleaseRuntimeCoherenceInput, "release" | "runtime">,
|
||||
initialMismatches: readonly string[] = [],
|
||||
): ReleaseRuntimeCoherence {
|
||||
const mismatches = [...initialMismatches];
|
||||
if (input.release.buildId !== input.runtime.BUILD_ID) {
|
||||
mismatches.push("buildId");
|
||||
}
|
||||
if (
|
||||
!isVersionCompatible(
|
||||
input.release.configSchemaVersion,
|
||||
input.runtime.CONFIG_SCHEMA_VERSION,
|
||||
)
|
||||
) {
|
||||
mismatches.push("configSchemaVersion");
|
||||
}
|
||||
const warnings =
|
||||
input.release.releaseId === input.runtime.RELEASE_ID
|
||||
? []
|
||||
: ["releaseId"];
|
||||
return Object.freeze({
|
||||
compatible: mismatches.length === 0,
|
||||
mismatches: Object.freeze(mismatches),
|
||||
warnings: Object.freeze(warnings),
|
||||
});
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import {
|
||||
verifyCompatibilityTuple,
|
||||
type CompatibilityTuple,
|
||||
} from "../src/application/policies/compatibility.ts";
|
||||
import {
|
||||
compareReleaseToRuntime,
|
||||
} from "../src/contracts/release-tokens.ts";
|
||||
import {
|
||||
parseBuildManifestArtifact,
|
||||
parseReleaseArtifact,
|
||||
@@ -17,13 +14,13 @@ import {
|
||||
type ReleaseArtifact,
|
||||
type RuntimeConfigArtifact,
|
||||
} from "../src/contracts/release-artifacts.ts";
|
||||
import { verifyContractSet } from "../src/contracts/contract-set.ts";
|
||||
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts";
|
||||
|
||||
type CoherenceFixture = Readonly<{
|
||||
name: string;
|
||||
@@ -71,22 +68,17 @@ const actualAssetManifestHash = createHash("sha256")
|
||||
.update(viteManifest)
|
||||
.digest("hex");
|
||||
|
||||
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
|
||||
const artifactComparison = await verifyReleaseRuntimeCoherence({
|
||||
release,
|
||||
runtime: runtimeConfig,
|
||||
contractPackages: EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
});
|
||||
const artifactMismatches: string[] = [...artifactComparison.mismatches];
|
||||
for (const [token, value] of Object.entries(projectReleaseTokens(release))) {
|
||||
if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) {
|
||||
artifactMismatches.push(`releaseToken:${token}`);
|
||||
}
|
||||
}
|
||||
if (release.schemaVersion === 2) {
|
||||
const contractSetVerification = await verifyContractSet({
|
||||
expected: EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
manifest: release.contractSet,
|
||||
});
|
||||
if (!contractSetVerification.ok) {
|
||||
artifactMismatches.push(contractSetVerification.code);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof release.builtAt !== "string" ||
|
||||
!Number.isFinite(Date.parse(release.builtAt))
|
||||
|
||||
Reference in New Issue
Block a user