fix: fail closed in release drill verification

This commit is contained in:
DongHyeonka
2026-08-02 03:54:58 +09:00
parent 990603e24a
commit 172a26b8bd
3 changed files with 216 additions and 65 deletions
+87 -44
View File
@@ -7,6 +7,7 @@ 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 type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
import {
parseReleaseArtifact,
parseRuntimeConfigArtifact,
@@ -14,10 +15,7 @@ import {
} 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";
import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts";
type RecoveryAssertion = Readonly<{
assertion: string;
@@ -48,20 +46,42 @@ type RunbookDocument = Readonly<{
runbooks: Record<string, RunbookSpecification>;
}>;
async function releaseManifest(): Promise<ReleaseArtifact> {
for (const candidate of [
"dist/release-manifest.json",
"public/release-manifest.json",
]) {
try {
return parseReleaseArtifact(
JSON.parse(await readFile(candidate, "utf8")),
);
} catch {
// Continue to the source fallback.
}
}
throw new Error("Release manifest is unavailable.");
export type JsonArtifactReader = (path: string) => Promise<unknown>;
export type RollbackArtifactPaths = Readonly<{
primaryRelease: string;
fallbackRelease: string;
primaryRuntime: string;
fallbackRuntime: string;
}>;
export type RollbackCoherenceOptions = Readonly<{
readArtifact?: JsonArtifactReader;
contractPackages?: readonly InstalledContractPackageIdentity[];
paths?: RollbackArtifactPaths;
}>;
const DEFAULT_ROLLBACK_PATHS = Object.freeze({
primaryRelease: "dist/release-manifest.json",
fallbackRelease: "public/release-manifest.json",
primaryRuntime: "dist/config.json",
fallbackRuntime: "public/config.json",
});
async function readJsonArtifact(path: string): Promise<unknown> {
return JSON.parse(await readFile(path, "utf8"));
}
async function releaseManifest(
readArtifact: JsonArtifactReader = readJsonArtifact,
paths: RollbackArtifactPaths = DEFAULT_ROLLBACK_PATHS,
): Promise<ReleaseArtifact> {
const value = await readPrimaryOrFallback(
readArtifact,
paths.primaryRelease,
paths.fallbackRelease,
);
return parseReleaseArtifact(value);
}
const validConfig = {
@@ -227,30 +247,8 @@ async function drillTelemetry(): Promise<DrillResult> {
}
async function drillRollback(): Promise<DrillResult> {
const release = await releaseManifest();
const runtimeArtifact = parseRuntimeConfigArtifact(
JSON.parse(
await readFile(
(await access("dist/config.json").then(() => true).catch(() => false))
? "dist/config.json"
: "public/config.json",
"utf8",
),
),
);
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 verified = await verifyRollbackReleaseCoherence();
const { release, coherence: coherent } = verified;
const mixed = verifyCompatibilityTuple({
frontend: {
buildId: "build-a",
@@ -290,9 +288,32 @@ const drillById: Record<string, () => Promise<DrillResult>> = {
};
export async function verifyRollbackReleaseCoherence(
input: ReleaseRuntimeCoherenceInput,
options: RollbackCoherenceOptions = {},
) {
return verifyReleaseRuntimeCoherence(input);
const readArtifact = options.readArtifact ?? readJsonArtifact;
const paths = options.paths ?? DEFAULT_ROLLBACK_PATHS;
const release = await releaseManifest(readArtifact, paths);
const runtimeValue = await readPrimaryOrFallback(
readArtifact,
paths.primaryRuntime,
paths.fallbackRuntime,
);
const runtimeArtifact = parseRuntimeConfigArtifact(runtimeValue);
const runtime = {
...runtimeArtifact,
BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"),
RELEASE_ID: requireIdentity(
runtimeArtifact.RELEASE_ID,
"runtime RELEASE_ID",
),
};
const coherence = await verifyReleaseRuntimeCoherence({
release,
runtime,
contractPackages:
options.contractPackages ?? EXPECTED_CONTRACT_SET_PACKAGES,
});
return Object.freeze({ release, runtime, coherence });
}
async function main(): Promise<void> {
@@ -356,6 +377,28 @@ function requireIdentity(value: string | undefined, label: string): string {
return value;
}
async function readPrimaryOrFallback(
readArtifact: JsonArtifactReader,
primary: string,
fallback: string,
): Promise<unknown> {
try {
return await readArtifact(primary);
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
return readArtifact(fallback);
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
error.code === code,
);
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&