213 lines
8.3 KiB
JavaScript
213 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",
|
|
"scripts/ci/verify-reviewed-plan.test.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;
|
|
}
|