fix: fail closed in release drill verification
This commit is contained in:
+87
-44
@@ -7,6 +7,7 @@ import { verifyCompatibilityTuple } from "../src/application/policies/compatibil
|
|||||||
import type { StoragePort } from "../src/application/ports/storage-port.ts";
|
import type { StoragePort } from "../src/application/ports/storage-port.ts";
|
||||||
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
|
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
|
||||||
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
|
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
|
||||||
|
import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
|
||||||
import {
|
import {
|
||||||
parseReleaseArtifact,
|
parseReleaseArtifact,
|
||||||
parseRuntimeConfigArtifact,
|
parseRuntimeConfigArtifact,
|
||||||
@@ -14,10 +15,7 @@ import {
|
|||||||
} from "../src/contracts/release-artifacts.ts";
|
} from "../src/contracts/release-artifacts.ts";
|
||||||
import { projectTelemetryEvent } from "../src/contracts/telemetry.ts";
|
import { projectTelemetryEvent } from "../src/contracts/telemetry.ts";
|
||||||
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
|
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
|
||||||
import {
|
import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts";
|
||||||
verifyReleaseRuntimeCoherence,
|
|
||||||
type ReleaseRuntimeCoherenceInput,
|
|
||||||
} from "./lib/release-runtime-coherence.ts";
|
|
||||||
|
|
||||||
type RecoveryAssertion = Readonly<{
|
type RecoveryAssertion = Readonly<{
|
||||||
assertion: string;
|
assertion: string;
|
||||||
@@ -48,20 +46,42 @@ type RunbookDocument = Readonly<{
|
|||||||
runbooks: Record<string, RunbookSpecification>;
|
runbooks: Record<string, RunbookSpecification>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
async function releaseManifest(): Promise<ReleaseArtifact> {
|
export type JsonArtifactReader = (path: string) => Promise<unknown>;
|
||||||
for (const candidate of [
|
|
||||||
"dist/release-manifest.json",
|
export type RollbackArtifactPaths = Readonly<{
|
||||||
"public/release-manifest.json",
|
primaryRelease: string;
|
||||||
]) {
|
fallbackRelease: string;
|
||||||
try {
|
primaryRuntime: string;
|
||||||
return parseReleaseArtifact(
|
fallbackRuntime: string;
|
||||||
JSON.parse(await readFile(candidate, "utf8")),
|
}>;
|
||||||
);
|
|
||||||
} catch {
|
export type RollbackCoherenceOptions = Readonly<{
|
||||||
// Continue to the source fallback.
|
readArtifact?: JsonArtifactReader;
|
||||||
}
|
contractPackages?: readonly InstalledContractPackageIdentity[];
|
||||||
}
|
paths?: RollbackArtifactPaths;
|
||||||
throw new Error("Release manifest is unavailable.");
|
}>;
|
||||||
|
|
||||||
|
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 = {
|
const validConfig = {
|
||||||
@@ -227,30 +247,8 @@ async function drillTelemetry(): Promise<DrillResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function drillRollback(): Promise<DrillResult> {
|
async function drillRollback(): Promise<DrillResult> {
|
||||||
const release = await releaseManifest();
|
const verified = await verifyRollbackReleaseCoherence();
|
||||||
const runtimeArtifact = parseRuntimeConfigArtifact(
|
const { release, coherence: coherent } = verified;
|
||||||
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 mixed = verifyCompatibilityTuple({
|
const mixed = verifyCompatibilityTuple({
|
||||||
frontend: {
|
frontend: {
|
||||||
buildId: "build-a",
|
buildId: "build-a",
|
||||||
@@ -290,9 +288,32 @@ const drillById: Record<string, () => Promise<DrillResult>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function verifyRollbackReleaseCoherence(
|
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> {
|
async function main(): Promise<void> {
|
||||||
@@ -356,6 +377,28 @@ function requireIdentity(value: string | undefined, label: string): string {
|
|||||||
return value;
|
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];
|
const invokedPath = process.argv[1];
|
||||||
if (
|
if (
|
||||||
invokedPath !== undefined &&
|
invokedPath !== undefined &&
|
||||||
|
|||||||
+53
-11
@@ -1,10 +1,12 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyCompatibilityTuple,
|
verifyCompatibilityTuple,
|
||||||
type CompatibilityTuple,
|
type CompatibilityTuple,
|
||||||
} from "../src/application/policies/compatibility.ts";
|
} from "../src/application/policies/compatibility.ts";
|
||||||
|
import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts";
|
||||||
import {
|
import {
|
||||||
parseBuildManifestArtifact,
|
parseBuildManifestArtifact,
|
||||||
parseReleaseArtifact,
|
parseReleaseArtifact,
|
||||||
@@ -36,17 +38,53 @@ type ViteManifestEntry = Readonly<{
|
|||||||
isDynamicEntry?: boolean;
|
isDynamicEntry?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ReleaseArtifactReader = (path: string) => Promise<unknown>;
|
||||||
|
|
||||||
|
export type ReleaseArtifactsCoherenceOptions = Readonly<{
|
||||||
|
readArtifact?: ReleaseArtifactReader;
|
||||||
|
contractPackages?: readonly InstalledContractPackageIdentity[];
|
||||||
|
paths?: Readonly<{ release: string; runtime: string }>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const DEFAULT_RELEASE_COHERENCE_PATHS = Object.freeze({
|
||||||
|
release: "dist/release-manifest.json",
|
||||||
|
runtime: "dist/config.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function readJsonArtifact(path: string): Promise<unknown> {
|
||||||
|
return JSON.parse(await readFile(path, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyReleaseArtifactsCoherence(
|
||||||
|
options: ReleaseArtifactsCoherenceOptions = {},
|
||||||
|
) {
|
||||||
|
const readArtifact = options.readArtifact ?? readJsonArtifact;
|
||||||
|
const paths = options.paths ?? DEFAULT_RELEASE_COHERENCE_PATHS;
|
||||||
|
const release = parseReleaseDocument(await readArtifact(paths.release));
|
||||||
|
const runtime = parseRuntimeConfigDocument(
|
||||||
|
await readArtifact(paths.runtime),
|
||||||
|
);
|
||||||
|
const coherence = await verifyReleaseRuntimeCoherence({
|
||||||
|
release,
|
||||||
|
runtime,
|
||||||
|
contractPackages:
|
||||||
|
options.contractPackages ?? EXPECTED_CONTRACT_SET_PACKAGES,
|
||||||
|
});
|
||||||
|
return Object.freeze({ release, runtime, coherence });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
const fixturesDocument = parseFixturesDocument(
|
const fixturesDocument = parseFixturesDocument(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
await readFile("config/release/coherence-fixtures.json", "utf8"),
|
await readFile("config/release/coherence-fixtures.json", "utf8"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const release = parseReleaseDocument(
|
const verifiedRuntime = await verifyReleaseArtifactsCoherence();
|
||||||
JSON.parse(await readFile("dist/release-manifest.json", "utf8")),
|
const {
|
||||||
);
|
release,
|
||||||
const runtimeConfig = parseRuntimeConfigDocument(
|
runtime: runtimeConfig,
|
||||||
JSON.parse(await readFile("dist/config.json", "utf8")),
|
coherence: artifactComparison,
|
||||||
);
|
} = verifiedRuntime;
|
||||||
const buildManifestDocument: unknown = JSON.parse(
|
const buildManifestDocument: unknown = JSON.parse(
|
||||||
await readFile("artifacts/release/build-manifest.json", "utf8"),
|
await readFile("artifacts/release/build-manifest.json", "utf8"),
|
||||||
);
|
);
|
||||||
@@ -68,11 +106,6 @@ const actualAssetManifestHash = createHash("sha256")
|
|||||||
.update(viteManifest)
|
.update(viteManifest)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
|
||||||
const artifactComparison = await verifyReleaseRuntimeCoherence({
|
|
||||||
release,
|
|
||||||
runtime: runtimeConfig,
|
|
||||||
contractPackages: EXPECTED_CONTRACT_SET_PACKAGES,
|
|
||||||
});
|
|
||||||
const artifactMismatches: string[] = [...artifactComparison.mismatches];
|
const artifactMismatches: string[] = [...artifactComparison.mismatches];
|
||||||
for (const [token, value] of Object.entries(projectReleaseTokens(release))) {
|
for (const [token, value] of Object.entries(projectReleaseTokens(release))) {
|
||||||
if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) {
|
if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) {
|
||||||
@@ -193,6 +226,15 @@ if (!passed) {
|
|||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`,
|
`Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokedPath = process.argv[1];
|
||||||
|
if (
|
||||||
|
invokedPath !== undefined &&
|
||||||
|
import.meta.url === pathToFileURL(invokedPath).href
|
||||||
|
) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
|
|
||||||
function parseFixturesDocument(value: unknown): Readonly<{
|
function parseFixturesDocument(value: unknown): Readonly<{
|
||||||
fixtures: readonly CoherenceFixture[];
|
fixtures: readonly CoherenceFixture[];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { verifyRollbackReleaseCoherence } from "../../scripts/drill-runbook.ts";
|
import { verifyRollbackReleaseCoherence } from "../../scripts/drill-runbook.ts";
|
||||||
import { verifyReleaseRuntimeCoherence } from "../../scripts/lib/release-runtime-coherence.ts";
|
import { verifyReleaseArtifactsCoherence } from "../../scripts/verify-release.ts";
|
||||||
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
|
import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts";
|
||||||
import { computeContractSetDigest } from "../../src/contracts/contract-set-canonical.ts";
|
import { computeContractSetDigest } from "../../src/contracts/contract-set-canonical.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -98,6 +98,19 @@ async function releaseV2With(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function artifactReader(
|
||||||
|
entries: Readonly<Record<string, unknown | Error>>,
|
||||||
|
): (path: string) => Promise<unknown> {
|
||||||
|
return async (path) => {
|
||||||
|
const value = entries[path];
|
||||||
|
if (value === undefined) {
|
||||||
|
throw Object.assign(new Error(`missing ${path}`), { code: "ENOENT" });
|
||||||
|
}
|
||||||
|
if (value instanceof Error) throw value;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("release coherence", () => {
|
describe("release coherence", () => {
|
||||||
it("owns all nine release tokens and keeps builtAt diagnostic-only", () => {
|
it("owns all nine release tokens and keeps builtAt diagnostic-only", () => {
|
||||||
// §5.2 adds contractSetDigest beside the legacy apiContractVersion scalar.
|
// §5.2 adds contractSetDigest beside the legacy apiContractVersion scalar.
|
||||||
@@ -234,17 +247,25 @@ describe("release coherence", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const fixture of matrix) {
|
for (const fixture of matrix) {
|
||||||
const input = {
|
const input = { release: fixture.release, runtime: fixture.runtime };
|
||||||
release: fixture.release,
|
|
||||||
runtime: fixture.runtime,
|
|
||||||
contractPackages,
|
|
||||||
};
|
|
||||||
const verifierVerdict = (
|
const verifierVerdict = (
|
||||||
await verifyReleaseRuntimeCoherence(input)
|
await verifyReleaseArtifactsCoherence({
|
||||||
).compatible;
|
readArtifact: artifactReader({
|
||||||
|
"dist/release-manifest.json": input.release,
|
||||||
|
"dist/config.json": input.runtime,
|
||||||
|
}),
|
||||||
|
contractPackages,
|
||||||
|
})
|
||||||
|
).coherence.compatible;
|
||||||
const rollbackDrillVerdict = (
|
const rollbackDrillVerdict = (
|
||||||
await verifyRollbackReleaseCoherence(input)
|
await verifyRollbackReleaseCoherence({
|
||||||
).compatible;
|
readArtifact: artifactReader({
|
||||||
|
"dist/release-manifest.json": fixture.release,
|
||||||
|
"dist/config.json": fixture.runtime,
|
||||||
|
}),
|
||||||
|
contractPackages,
|
||||||
|
})
|
||||||
|
).coherence.compatible;
|
||||||
expect(verifierVerdict, `${fixture.name}: verifier`).toBe(
|
expect(verifierVerdict, `${fixture.name}: verifier`).toBe(
|
||||||
fixture.expectedCompatible,
|
fixture.expectedCompatible,
|
||||||
);
|
);
|
||||||
@@ -256,4 +277,49 @@ describe("release coherence", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to public rollback artifacts only when primary dist is absent", async () => {
|
||||||
|
const exactV2 = await releaseV2With(contractPackages);
|
||||||
|
const tamperedPrimary = await releaseV2With(contractPackages.slice(0, 1));
|
||||||
|
const validPublic = {
|
||||||
|
"public/release-manifest.json": exactV2,
|
||||||
|
"public/config.json": runtimeV2,
|
||||||
|
};
|
||||||
|
const unavailablePrimary = [
|
||||||
|
new SyntaxError("invalid primary JSON"),
|
||||||
|
Object.assign(new Error("unreadable primary"), { code: "EACCES" }),
|
||||||
|
{ ...exactV2, unexpected: "tampered" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const primaryFailure of unavailablePrimary) {
|
||||||
|
await expect(
|
||||||
|
verifyRollbackReleaseCoherence({
|
||||||
|
readArtifact: artifactReader({
|
||||||
|
"dist/release-manifest.json": primaryFailure,
|
||||||
|
"dist/config.json": runtimeV2,
|
||||||
|
...validPublic,
|
||||||
|
}),
|
||||||
|
contractPackages,
|
||||||
|
}),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyRollbackReleaseCoherence({
|
||||||
|
readArtifact: artifactReader({
|
||||||
|
"dist/release-manifest.json": tamperedPrimary,
|
||||||
|
"dist/config.json": runtimeV2,
|
||||||
|
...validPublic,
|
||||||
|
}),
|
||||||
|
contractPackages,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ coherence: { compatible: false } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyRollbackReleaseCoherence({
|
||||||
|
readArtifact: artifactReader(validPublic),
|
||||||
|
contractPackages,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ coherence: { compatible: true } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user