Files
pikasTech-agentrun/scripts/ci-plan.mjs
T
2026-07-22 06:46:18 +02:00

212 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const serviceId = "agentrun-mgr";
const envIdentityExcludedPaths = new Set([
"scripts/ci-plan.mjs",
"scripts/ci-plan.test.mjs",
"scripts/ci/verify-reviewed-plan.mjs",
]);
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const options = parseArgs(process.argv.slice(2));
const root = resolve(options.root);
const sourceCommitId = git(root, ["rev-parse", options.targetRef]);
const baseCommitId = git(root, ["rev-parse", options.baseRef]);
const sourceChangedPaths = git(root, ["diff", "--name-only", baseCommitId, sourceCommitId]).split("\n").filter(Boolean);
const materializationPaths = Array.isArray(options.materializationPaths) ? options.materializationPaths : [];
const changedPaths = materializationPaths.length === 0
? sourceChangedPaths
: sourceChangedPaths.filter((changedPath) => materializationPaths.some((inputPath) => changedPath === inputPath || changedPath.startsWith(`${inputPath}/`)));
const envIdentity = calculateEnvIdentity({
root,
envIdentityFiles: JSON.parse(options.envIdentityFilesJson),
buildArgs: JSON.parse(options.buildArgsJson),
containerHttpProxy: options.containerHttpProxy,
containerHttpsProxy: options.containerHttpsProxy,
containerNoProxy: options.containerNoProxy,
});
const registryProbe = await probeRegistry({
registryProbeBase: options.registryProbeBase,
imageRepository: options.imageRepository,
envIdentity,
});
process.stdout.write(`${JSON.stringify(createPlan({
sourceCommitId,
baseCommitId,
target: options.target,
consumer: options.consumer,
lane: options.lane,
changedPaths,
envIdentity,
imageRepository: options.imageRepository,
registryProbe,
}))}\n`);
}
export function calculateEnvIdentity(input) {
const root = resolve(input.root);
const hash = createHash("sha256");
for (const item of input.buildArgs) {
hash.update("build-arg\0");
hash.update(item);
hash.update("\0");
}
for (const item of [
`HTTP_PROXY=${input.containerHttpProxy || ""}`,
`HTTPS_PROXY=${input.containerHttpsProxy || ""}`,
`NO_PROXY=${input.containerNoProxy || ""}`,
]) {
hash.update("build-container-proxy\0");
hash.update(item);
hash.update("\0");
}
for (const inputPath of input.envIdentityFiles) {
for (const entry of collectFiles(root, inputPath)) {
hash.update(entry.path);
hash.update("\0");
if (!entry.missing) hash.update(readFileSync(join(root, entry.path)));
hash.update("\0");
}
}
return hash.digest("hex").slice(0, 24);
}
export function createPlan(input) {
const affectedServices = input.changedPaths.length > 0 ? [serviceId] : [];
const rolloutServices = [...affectedServices];
const buildServices = affectedServices.length > 0 && input.registryProbe.status !== "present" ? [serviceId] : [];
const registryProbe = {
enabled: true,
services: [{
serviceId,
imageRepository: input.imageRepository,
envIdentity: input.envIdentity,
status: input.registryProbe.status,
digest: input.registryProbe.digest ?? null,
}],
};
const identityInput = {
version: "v1",
inputs: {
target: input.target,
consumer: input.consumer,
lane: input.lane,
sourceCommitId: input.sourceCommitId,
baseCommitId: input.baseCommitId,
catalog: null,
registryProbe,
},
scope: { buildServices, rolloutServices, affectedServices },
};
return {
sourceCommitId: input.sourceCommitId,
baseCommitId: input.baseCommitId,
changedPaths: input.changedPaths,
services: [{ serviceId, changedPaths: input.changedPaths }],
envIdentity: input.envIdentity,
imageRepository: input.imageRepository,
registryProbe,
envArtifactGroups: [{
id: "agentrun-runtime-env",
buildService: serviceId,
imageRepository: input.imageRepository,
buildRequired: buildServices.length > 0,
services: [serviceId],
buildServices,
consumerServices: [serviceId],
}],
reusedServices: input.registryProbe.status === "present" ? [serviceId] : [],
serviceReusedCount: input.registryProbe.status === "present" ? 1 : 0,
imageBuildSkippedServices: input.registryProbe.status === "present" ? [serviceId] : [],
buildServices,
rolloutServices,
affectedServices,
planIdentity: {
value: `sha256:${createHash("sha256").update(stable(identityInput)).digest("hex")}`,
inputs: identityInput.inputs,
scope: identityInput.scope,
},
valuesPrinted: false,
};
}
async function probeRegistry({ registryProbeBase, imageRepository, envIdentity }) {
const base = registryProbeBase.replace(/\/+$/u, "");
const repositoryPath = imageRepository.replace(/^[^/]+\//u, "");
let response;
try {
response = await fetch(`${base}/v2/${repositoryPath}/manifests/${envIdentity}`, {
method: "HEAD",
headers: { Accept: "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" },
signal: AbortSignal.timeout(5000),
});
} catch (error) {
throw new Error(`registry probe failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (response.status === 404) return { status: "missing", digest: null };
if (!response.ok) throw new Error(`registry probe failed with HTTP ${response.status}`);
const digest = response.headers.get("docker-content-digest");
if (!digest) throw new Error("registry probe succeeded without docker-content-digest");
return { status: "present", digest };
}
function collectFiles(root, inputPath) {
const absolute = join(root, inputPath);
if (!existsSync(absolute)) return [{ path: inputPath, missing: true }];
const stat = lstatSync(absolute);
if (stat.isFile()) return [{ path: inputPath, missing: false }];
if (!stat.isDirectory()) return [{ path: inputPath, missing: true }];
const skip = new Set([".git", ".worktree", ".state", "node_modules", "coverage", "tmp", ".tmp"]);
const files = [];
const stack = [absolute];
while (stack.length > 0) {
const directory = stack.pop();
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
if (entry.isDirectory() && skip.has(entry.name)) continue;
const child = join(directory, entry.name);
if (entry.isDirectory()) stack.push(child);
else if (entry.isFile()) files.push({ path: relative(root, child), missing: false });
}
}
return files
.filter((entry) => !envIdentityExcludedPaths.has(entry.path))
.sort((left, right) => left.path.localeCompare(right.path));
}
function stable(value) {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
return JSON.stringify(value);
}
function git(root, args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
}
function parseArgs(argv) {
const result = { root: "." };
for (let index = 0; index < argv.length; index += 1) {
const name = argv[index];
if (!name.startsWith("--")) throw new Error(`unexpected argument ${name}`);
const key = name.slice(2).replace(/-([a-z])/gu, (_match, letter) => letter.toUpperCase());
const value = argv[++index];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
result[key] = value;
}
if (typeof result.configFile === "string") {
const configured = JSON.parse(readFileSync(result.configFile, "utf8"));
for (const [key, value] of Object.entries(configured)) {
if (result[key] === undefined) result[key] = value;
}
}
for (const key of ["baseRef", "targetRef", "target", "consumer", "lane", "imageRepository", "registryProbeBase", "envIdentityFilesJson", "buildArgsJson", "containerHttpProxy", "containerHttpsProxy", "containerNoProxy"]) {
if (typeof result[key] !== "string") throw new Error(`--${key.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)} is required`);
}
return result;
}