260 lines
9.3 KiB
TypeScript
260 lines
9.3 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
export type DockerBuildSourceEntry =
|
|
| { file: string; mode: number; content: Buffer; linkTarget?: never }
|
|
| { file: string; mode: number; content?: never; linkTarget: string };
|
|
|
|
export type DockerBuildSyntheticSourceChange = {
|
|
file: string;
|
|
id: string;
|
|
from: string;
|
|
to: string;
|
|
};
|
|
|
|
export const nextBuildCacheTarget = "/app/apps/nextjs/.next/cache";
|
|
|
|
export type ExpectedDockerBuildCacheMount = {
|
|
logicalId: string;
|
|
target: string;
|
|
mustIncreaseUsage: boolean;
|
|
};
|
|
|
|
export type DockerBuildCacheMount = {
|
|
description: string | null;
|
|
logicalId: string | null;
|
|
recordId: string;
|
|
size: string | null;
|
|
target: string | null;
|
|
usageCount: number | null;
|
|
};
|
|
|
|
export type DockerBuildCacheSnapshot = {
|
|
cacheMountCount: number;
|
|
cacheMounts: DockerBuildCacheMount[];
|
|
recordCount: number;
|
|
};
|
|
|
|
export const fingerprintDockerBuildSource = (entries: DockerBuildSourceEntry[]) => {
|
|
const hash = createHash("sha256");
|
|
for (const entry of entries.toSorted((left, right) => left.file.localeCompare(right.file))) {
|
|
hash.update(entry.file);
|
|
hash.update("\0");
|
|
hash.update(String(entry.mode));
|
|
hash.update("\0");
|
|
hash.update(entry.linkTarget ?? entry.content);
|
|
hash.update("\0");
|
|
}
|
|
return hash.digest("hex");
|
|
};
|
|
|
|
export const applyDockerBuildSyntheticSourceChange = (
|
|
entries: DockerBuildSourceEntry[],
|
|
id: string,
|
|
): { entries: DockerBuildSourceEntry[]; change: DockerBuildSyntheticSourceChange } => {
|
|
const file = "apps/nextjs/src/app/[locale]/layout.tsx";
|
|
const from = 'siteName: "Homarr",';
|
|
const to = `siteName: ${JSON.stringify(`Homarr build benchmark ${id}`)},`;
|
|
let changed = false;
|
|
const nextEntries = entries.map((entry) => {
|
|
if (entry.file !== file || entry.content === undefined) return entry;
|
|
const source = entry.content.toString("utf8");
|
|
if (source.split(from).length !== 2) {
|
|
throw new Error(`Expected exactly one build benchmark source marker in ${file}`);
|
|
}
|
|
changed = true;
|
|
return { ...entry, content: Buffer.from(source.replace(from, to)) };
|
|
});
|
|
if (!changed) throw new Error(`Build benchmark source marker file is missing: ${file}`);
|
|
return { entries: nextEntries, change: { file, id, from, to } };
|
|
};
|
|
|
|
const expandDockerfileMountValue = (value: string, variables: Record<string, string>) => {
|
|
const expanded = value.replaceAll(
|
|
/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
|
|
(reference, braced, plain) => {
|
|
const name = (braced ?? plain) as string;
|
|
const replacement = variables[name];
|
|
if (replacement === undefined) {
|
|
throw new Error(`Dockerfile cache mount contains unresolved variable ${reference}`);
|
|
}
|
|
return replacement;
|
|
},
|
|
);
|
|
if (expanded.includes("$")) {
|
|
throw new Error(`Dockerfile cache mount contains unresolved variable in ${value}`);
|
|
}
|
|
return expanded;
|
|
};
|
|
|
|
export const parseDockerfileCacheMounts = (
|
|
dockerfile: string,
|
|
targetPlatform: string,
|
|
): ExpectedDockerBuildCacheMount[] => {
|
|
const mounts = [...dockerfile.matchAll(/--mount=([^\s\\]+)/g)]
|
|
.map(([, serialized = ""]) =>
|
|
Object.fromEntries(
|
|
serialized.split(",").map((option) => {
|
|
const separator = option.indexOf("=");
|
|
return separator === -1 ? [option, ""] : [option.slice(0, separator), option.slice(separator + 1)];
|
|
}),
|
|
),
|
|
)
|
|
.filter(({ type }) => type === "cache")
|
|
.map((options) => {
|
|
const id = options.id;
|
|
const target = options.target ?? options.dst ?? options.destination;
|
|
if (!id || !target) {
|
|
throw new Error("Dockerfile cache mounts must declare explicit id and target options");
|
|
}
|
|
const variables = { TARGETPLATFORM: targetPlatform };
|
|
const logicalId = expandDockerfileMountValue(id, variables);
|
|
const expandedTarget = expandDockerfileMountValue(target, variables);
|
|
return {
|
|
logicalId,
|
|
target: expandedTarget,
|
|
mustIncreaseUsage: expandedTarget === nextBuildCacheTarget,
|
|
};
|
|
});
|
|
|
|
const uniqueMounts = new Map(mounts.map((mount) => [`${mount.logicalId}\0${mount.target}`, mount]));
|
|
return [...uniqueMounts.values()].toSorted(
|
|
(left, right) => left.logicalId.localeCompare(right.logicalId) || left.target.localeCompare(right.target),
|
|
);
|
|
};
|
|
|
|
export const parseDockerBuildCacheMountDescription = (description: string | null) => {
|
|
if (!description) return null;
|
|
const match = /^cached mount (\S+) from exec .* with id "(\/[^"\n]+)"$/.exec(description);
|
|
if (!match) return null;
|
|
const [, target = "", printedId = ""] = match;
|
|
return { logicalId: printedId.slice(1), target };
|
|
};
|
|
|
|
export const normalizeDockerArchitecture = (architecture: string) => {
|
|
switch (architecture.toLowerCase()) {
|
|
case "x64":
|
|
case "x86_64":
|
|
return "amd64";
|
|
case "aarch64":
|
|
return "arm64";
|
|
case "armv6l":
|
|
return "arm/v6";
|
|
case "armv7l":
|
|
return "arm/v7";
|
|
default:
|
|
return architecture.toLowerCase();
|
|
}
|
|
};
|
|
|
|
export const parseDockerBuildOutput = (output: string) => {
|
|
const compileDurationsMs = [...output.matchAll(/Compiled (?:successfully )?in\s+([\d.]+)\s*(ms|s)/gi)].map(
|
|
([, value = "0", unit = "ms"]) => Number(value) * (unit.toLowerCase() === "s" ? 1_000 : 1),
|
|
);
|
|
|
|
return {
|
|
cachedStepCount: (output.match(/^#\d+ CACHED$/gm) ?? []).length,
|
|
completedStepCount: (output.match(/^#\d+ DONE(?:\s+[\d.]+s)?$/gm) ?? []).length,
|
|
nextCompileMs: compileDurationsMs.at(-1) ?? null,
|
|
};
|
|
};
|
|
|
|
export const getMeasuredRevision = (headSha: string, dirty: boolean) => (dirty ? `${headSha}+worktree` : headSha);
|
|
|
|
export const getBuildCacheStateIneligibleReasons = (
|
|
cacheState: string | null,
|
|
snapshot: DockerBuildCacheSnapshot | null,
|
|
expectedMounts: ExpectedDockerBuildCacheMount[],
|
|
) => {
|
|
if (cacheState !== "cold" && cacheState !== "warm") {
|
|
return ["BUILD_BENCHMARK_CACHE_STATE must be cold or warm"];
|
|
}
|
|
if (!snapshot) return ["Docker builder cache state could not be inspected"];
|
|
if (cacheState === "cold" && snapshot.recordCount !== 0) {
|
|
return [`cold builder already contains ${snapshot.recordCount} cache records`];
|
|
}
|
|
if (cacheState === "warm" && expectedMounts.length === 0) {
|
|
return ["Dockerfile declares no cache mounts for a warm build"];
|
|
}
|
|
if (cacheState === "warm") {
|
|
return expectedMounts.flatMap((expected) => {
|
|
const matches = snapshot.cacheMounts.filter(
|
|
({ logicalId, target }) => logicalId === expected.logicalId && target === expected.target,
|
|
);
|
|
if (matches.length === 0) {
|
|
return [`warm builder is missing cache mount ${expected.logicalId} at ${expected.target}`];
|
|
}
|
|
if (matches.length > 1) {
|
|
return [`warm builder contains duplicate cache mounts ${expected.logicalId} at ${expected.target}`];
|
|
}
|
|
return [];
|
|
});
|
|
}
|
|
return [];
|
|
};
|
|
|
|
export const getBuildCacheContinuityIneligibleReasons = (
|
|
cacheState: string | null,
|
|
before: DockerBuildCacheSnapshot | null,
|
|
after: DockerBuildCacheSnapshot | null,
|
|
expectedMounts: ExpectedDockerBuildCacheMount[],
|
|
) => {
|
|
if (cacheState !== "warm") return [];
|
|
if (!before || !after) return ["warm builder cache continuity could not be inspected"];
|
|
|
|
return expectedMounts.flatMap((expected) => {
|
|
const beforeMatches = before.cacheMounts.filter(
|
|
({ logicalId, target }) => logicalId === expected.logicalId && target === expected.target,
|
|
);
|
|
const afterMatches = after.cacheMounts.filter(
|
|
({ logicalId, target }) => logicalId === expected.logicalId && target === expected.target,
|
|
);
|
|
if (beforeMatches.length !== 1 || afterMatches.length !== 1) {
|
|
return [`warm cache mount ${expected.logicalId} at ${expected.target} could not be traced across the build`];
|
|
}
|
|
const beforeMount = beforeMatches[0];
|
|
const afterMount = afterMatches[0];
|
|
if (!beforeMount || !afterMount || beforeMount.recordId !== afterMount.recordId) {
|
|
return [`warm cache mount ${expected.logicalId} at ${expected.target} was replaced during the build`];
|
|
}
|
|
if (!expected.mustIncreaseUsage) return [];
|
|
if (beforeMount.usageCount === null || afterMount.usageCount === null) {
|
|
return [`warm cache mount ${expected.logicalId} usage could not be verified`];
|
|
}
|
|
if (afterMount.usageCount <= beforeMount.usageCount) {
|
|
return [`warm cache mount ${expected.logicalId} was not used by the application build`];
|
|
}
|
|
return [];
|
|
});
|
|
};
|
|
|
|
export const getDockerBuildClaimIneligibleReasons = ({
|
|
expectedRevision,
|
|
expectedSourceFingerprint,
|
|
imageArchitecture,
|
|
imageRevision,
|
|
imageSourceFingerprint,
|
|
expectedImageArchitecture,
|
|
}: {
|
|
expectedRevision: string | null;
|
|
expectedSourceFingerprint: string;
|
|
imageArchitecture: string;
|
|
imageRevision: string | null;
|
|
imageSourceFingerprint: string | null;
|
|
expectedImageArchitecture: string;
|
|
}) => {
|
|
const reasons: string[] = [];
|
|
|
|
if (!expectedRevision) reasons.push("BUILD_BENCHMARK_REVISION is missing");
|
|
else if (imageRevision !== expectedRevision) {
|
|
reasons.push(`image revision ${imageRevision ?? "missing"} does not match ${expectedRevision}`);
|
|
}
|
|
if (imageSourceFingerprint !== expectedSourceFingerprint) {
|
|
reasons.push("image source fingerprint does not match the measured context");
|
|
}
|
|
if (imageArchitecture !== expectedImageArchitecture) {
|
|
reasons.push(`image architecture ${imageArchitecture} is not Docker server ${expectedImageArchitecture}`);
|
|
}
|
|
|
|
return reasons;
|
|
};
|