fix: unify release runtime coherence verification

This commit is contained in:
DongHyeonka
2026-08-02 03:38:49 +09:00
parent 184bd98d92
commit 990603e24a
5 changed files with 406 additions and 98 deletions
+110 -70
View File
@@ -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`,
);
+85
View File
@@ -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),
});
}
+6 -14
View File
@@ -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))