paperclip/tests/runner-e2e/history-publish.ts

500 lines
14 KiB
TypeScript

import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";
import {
mkdtemp,
lstat,
readFile,
readdir,
rm,
stat,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import { regenerateRunnerDashboard } from "./dashboard-regenerate.js";
import { renderRunnerHistoryIndex } from "./history-index.js";
import {
campaignHistoryRecord,
emptyRunnerHistory,
mergeRunnerHistory,
} from "./history.js";
import type { RunnerE2ECampaign, RunnerE2EHistoryIndex } from "./types.js";
const execFileAsync = promisify(execFile);
const MUTABLE_HISTORY_FILES = new Set([
"history.json",
"latest.json",
"latest-green.json",
]);
const PUBLISH_ROOT_FILES = new Set([
"dashboard.html",
"index.html",
"junit.xml",
"normalized-results.json",
"summary.md",
]);
const PUBLIC_EVIDENCE_EXTENSIONS = new Set([".json", ".log", ".md", ".txt"]);
const PRIVATE_EVIDENCE_DIRECTORIES = new Set([
"blob-report",
"html-report",
"playwright-output",
]);
function publicEvidencePath(relative: string) {
const match = relative.match(
/^evidence\/[A-Za-z0-9._-]+\/attempt-[1-9][0-9]*\/(.+)$/,
);
if (!match) return false;
const evidencePath = match[1]!;
const segments = evidencePath.split("/");
if (
segments.some(
(segment) =>
!segment ||
segment === "." ||
segment === ".." ||
PRIVATE_EVIDENCE_DIRECTORIES.has(segment),
)
) {
return false;
}
return PUBLIC_EVIDENCE_EXTENSIONS.has(
path.posix.extname(evidencePath).toLowerCase(),
);
}
export function isHistoricalBundlePathAllowed(relative: string) {
if (
relative.includes("\\") ||
relative.startsWith("/") ||
relative.includes("..")
) {
return false;
}
if (PUBLISH_ROOT_FILES.has(relative)) return true;
if (
relative === "assets/favicon.svg" ||
relative === "assets/InterVariable.woff2"
) {
return true;
}
return publicEvidencePath(relative);
}
async function pruneEvidenceDirectory(root: string, current: string) {
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
await pruneEvidenceDirectory(root, absolute);
if ((await readdir(absolute)).length === 0) {
await rm(absolute, { recursive: true });
}
continue;
}
const relative = path.relative(root, absolute).split(path.sep).join("/");
if (!entry.isFile() || !publicEvidencePath(relative)) {
await rm(absolute, { force: true });
}
}
}
export async function prunePrivateHistoryEvidence(root: string) {
const evidenceRoot = path.join(root, "evidence");
const metadata = await lstat(evidenceRoot).catch(() => null);
if (!metadata) return;
if (!metadata.isDirectory()) {
throw new Error("Historical evidence root must be a directory");
}
await pruneEvidenceDirectory(root, evidenceRoot);
}
interface BundleManifest {
schema: "paperclip.runner-e2e.bundle/v1";
campaignId: string;
bundleDigest: string;
files: Array<{ path: string; sha256: string; bytes: number }>;
}
function json(value: unknown) {
return `${JSON.stringify(value, null, 2)}\n`;
}
export function validateHistoryDestination(input: {
bucket: string;
prefix: string;
publicBaseUrl: string;
}) {
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(input.bucket)) {
throw new Error("RUNNER_E2E_HISTORY_S3_BUCKET is not a valid bucket name");
}
const prefix = input.prefix.replace(/^\/+|\/+$/g, "");
if (
!prefix ||
prefix
.split("/")
.some((segment) => !segment || segment === "." || segment === "..")
) {
throw new Error(
"RUNNER_E2E_HISTORY_PREFIX must be a safe non-empty key prefix",
);
}
const publicUrl = new URL(input.publicBaseUrl);
if (
publicUrl.protocol !== "https:" ||
publicUrl.username ||
publicUrl.password ||
publicUrl.search ||
publicUrl.hash
) {
throw new Error(
"RUNNER_E2E_HISTORY_PUBLIC_BASE_URL must be a credential-free HTTPS URL",
);
}
return { prefix, publicBaseUrl: publicUrl.href.replace(/\/$/, "") };
}
async function relativeFiles(root: string, current = root): Promise<string[]> {
const entries = await readdir(current, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Refusing to publish symbolic link ${entry.name}`);
}
if (entry.isDirectory()) {
files.push(...(await relativeFiles(root, absolute)));
} else if (entry.isFile()) {
const relative = path.relative(root, absolute).split(path.sep).join("/");
if (MUTABLE_HISTORY_FILES.has(relative)) continue;
if (!isHistoricalBundlePathAllowed(relative)) {
throw new Error(
`Refusing non-allowlisted historical bundle path ${relative}`,
);
}
files.push(relative);
}
}
return files;
}
export async function createBundleManifest(
root: string,
campaignId: string,
): Promise<BundleManifest> {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(campaignId)) {
throw new Error("Campaign ID is unsafe for immutable object storage");
}
const files = await Promise.all(
(await relativeFiles(root)).sort().map(async (relative) => {
const absolute = path.join(root, ...relative.split("/"));
const [content, metadata] = await Promise.all([
readFile(absolute),
stat(absolute),
]);
return {
path: relative,
sha256: createHash("sha256").update(content).digest("hex"),
bytes: metadata.size,
};
}),
);
const bundleDigest = createHash("sha256")
.update(JSON.stringify(files))
.digest("hex");
return {
schema: "paperclip.runner-e2e.bundle/v1",
campaignId,
bundleDigest,
files,
};
}
export function buildHistoryPointers(history: RunnerE2EHistoryIndex) {
const byCampaign = new Map(
history.campaigns.map((campaign) => [campaign.campaignId, campaign]),
);
const pointer = (campaignId: string | null | undefined) => {
const campaign = campaignId ? byCampaign.get(campaignId) : undefined;
return campaign
? {
campaignId: campaign.campaignId,
generatedAt: campaign.generatedAt,
publicUrl: campaign.publicUrl,
sha: campaign.source.sha,
}
: null;
};
return {
latest: {
schema: "paperclip.runner-e2e.pointer/v1",
updatedAt: history.updatedAt,
overall: pointer(history.latestCampaignId),
suites: Object.fromEntries(
Object.entries(history.latestBySuite).map(([suiteId, campaignId]) => [
suiteId,
pointer(campaignId),
]),
),
},
latestGreen: {
schema: "paperclip.runner-e2e.pointer/v1",
updatedAt: history.updatedAt,
overall: pointer(history.latestGreenCampaignId),
suites: Object.fromEntries(
Object.entries(history.latestGreenBySuite).map(
([suiteId, campaignId]) => [suiteId, pointer(campaignId)],
),
),
},
};
}
function awsObject(bucket: string, key: string) {
return `s3://${bucket}/${key}`;
}
async function objectExists(bucket: string, key: string) {
try {
await execFileAsync("aws", [
"s3api",
"head-object",
"--bucket",
bucket,
"--key",
key,
]);
return true;
} catch (error) {
const detail = String(
(error as { stderr?: string }).stderr ??
(error instanceof Error ? error.message : error),
);
if (/\b(?:404|Not Found|NoSuchKey)\b/i.test(detail)) return false;
throw new Error(
`Unable to inspect historical object: ${detail.slice(0, 400)}`,
);
}
}
async function downloadJson<T>(
bucket: string,
key: string,
destination: string,
) {
if (!(await objectExists(bucket, key))) return null;
await execFileAsync("aws", [
"s3",
"cp",
awsObject(bucket, key),
destination,
"--only-show-errors",
]);
return JSON.parse(await readFile(destination, "utf8")) as T;
}
async function uploadJson(
bucket: string,
key: string,
file: string,
cacheControl: string,
) {
await uploadFile(bucket, key, file, "application/json", cacheControl);
}
async function uploadFile(
bucket: string,
key: string,
file: string,
contentType: string,
cacheControl: string,
) {
await execFileAsync("aws", [
"s3",
"cp",
file,
awsObject(bucket, key),
"--only-show-errors",
"--content-type",
contentType,
"--cache-control",
cacheControl,
]);
}
async function main() {
const reportRoot = path.resolve(
process.env.PAPERCLIP_RUNNER_E2E_REPORT_DIR ??
"runner-e2e-merged-report/normalized",
);
const bucket = process.env.RUNNER_E2E_HISTORY_S3_BUCKET ?? "";
const destination = validateHistoryDestination({
bucket,
prefix: process.env.RUNNER_E2E_HISTORY_PREFIX ?? "runner-e2e",
publicBaseUrl: process.env.RUNNER_E2E_HISTORY_PUBLIC_BASE_URL ?? "",
});
const campaign = JSON.parse(
await readFile(path.join(reportRoot, "normalized-results.json"), "utf8"),
) as RunnerE2ECampaign;
if (campaign.schema !== "paperclip.runner-e2e.campaign/v2") {
throw new Error("Historical publishing requires a v2 normalized campaign");
}
const temporary = await mkdtemp(
path.join(os.tmpdir(), "runner-e2e-history-"),
);
const historyKey = `${destination.prefix}/history.json`;
const current =
(await downloadJson<RunnerE2EHistoryIndex>(
bucket,
historyKey,
path.join(temporary, "current-history.json"),
)) ?? emptyRunnerHistory();
const history = mergeRunnerHistory(
current,
campaignHistoryRecord(
campaign,
`${destination.publicBaseUrl}/${destination.prefix}`,
),
);
// Campaign bundles are immutable and must not capture a mutable history
// file left in a reused local directory. The root landing page below is the
// only dashboard that embeds navigation across campaigns.
// Raster/video pixels are not OCR-scanned for secrets, and generated HTML,
// archives, and SVG may contain or execute active/private content. Preserve
// those in the access-controlled workflow artifact but remove them from the
// directory shared by public S3 and Pages publication.
await prunePrivateHistoryEvidence(reportRoot);
await regenerateRunnerDashboard({ bundle: reportRoot, historyFile: null });
const manifest = await createBundleManifest(reportRoot, campaign.campaignId);
const campaignPrefix = `${destination.prefix}/campaigns/${campaign.campaignId}`;
const manifestKey = `${campaignPrefix}/bundle-manifest.json`;
const existingManifest = await downloadJson<BundleManifest>(
bucket,
manifestKey,
path.join(temporary, "existing-manifest.json"),
);
if (
existingManifest &&
existingManifest.bundleDigest !== manifest.bundleDigest
) {
throw new Error(
`Immutable campaign ${campaign.campaignId} already exists with a different digest`,
);
}
if (!existingManifest) {
await execFileAsync("aws", [
"s3",
"cp",
reportRoot,
awsObject(bucket, campaignPrefix),
"--recursive",
"--only-show-errors",
"--exclude",
"history.json",
"--exclude",
"latest.json",
"--exclude",
"latest-green.json",
"--cache-control",
"public,max-age=31536000,immutable",
]);
const manifestFile = path.join(temporary, "bundle-manifest.json");
await writeFile(manifestFile, json(manifest), "utf8");
await uploadJson(
bucket,
manifestKey,
manifestFile,
"public,max-age=31536000,immutable",
);
}
const pointers = buildHistoryPointers(history);
const historyFile = path.join(reportRoot, "history.json");
const latestFile = path.join(reportRoot, "latest.json");
const latestGreenFile = path.join(reportRoot, "latest-green.json");
await Promise.all([
writeFile(historyFile, json(history), "utf8"),
writeFile(latestFile, json(pointers.latest), "utf8"),
writeFile(latestGreenFile, json(pointers.latestGreen), "utf8"),
]);
await regenerateRunnerDashboard({ bundle: reportRoot, historyFile });
const landingDirectory = path.join(temporary, "landing");
await regenerateRunnerDashboard({
bundle: reportRoot,
historyFile,
outputDirectory: landingDirectory,
evidenceHrefPrefix: `campaigns/${campaign.campaignId}`,
});
await writeFile(
path.join(landingDirectory, "index.html"),
renderRunnerHistoryIndex(history),
"utf8",
);
await Promise.all([
uploadJson(bucket, historyKey, historyFile, "no-cache"),
uploadJson(
bucket,
`${destination.prefix}/latest.json`,
latestFile,
"no-cache",
),
uploadJson(
bucket,
`${destination.prefix}/latest-green.json`,
latestGreenFile,
"no-cache",
),
uploadFile(
bucket,
`${destination.prefix}/index.html`,
path.join(landingDirectory, "index.html"),
"text/html; charset=utf-8",
"no-cache",
),
uploadFile(
bucket,
`${destination.prefix}/dashboard.html`,
path.join(landingDirectory, "dashboard.html"),
"text/html; charset=utf-8",
"no-cache",
),
uploadFile(
bucket,
`${destination.prefix}/normalized-results.json`,
path.join(landingDirectory, "normalized-results.json"),
"application/json",
"no-cache",
),
uploadFile(
bucket,
`${destination.prefix}/assets/favicon.svg`,
path.join(reportRoot, "assets", "favicon.svg"),
"image/svg+xml",
"public,max-age=86400",
),
uploadFile(
bucket,
`${destination.prefix}/assets/InterVariable.woff2`,
path.join(reportRoot, "assets", "InterVariable.woff2"),
"font/woff2",
"public,max-age=86400",
),
]);
console.log(
`Published immutable campaign ${campaign.campaignId} (${manifest.bundleDigest}) and ${history.campaigns.length} history record(s)`,
);
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}