204 lines
7.4 KiB
JavaScript
204 lines
7.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Restore the node/lane artifact catalog from the GitOps branch before CI planning.
|
|
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
|
|
import {
|
|
artifactCatalogAuthorityPath,
|
|
artifactCatalogServiceCoverage,
|
|
createBootstrapArtifactCatalogAuthority,
|
|
createHydratedArtifactCatalogAuthority
|
|
} from "../src/artifact-catalog-authority.mjs";
|
|
|
|
const startedAt = Date.now();
|
|
|
|
function env(name) {
|
|
const value = process.env[name];
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function csvEnv(name) {
|
|
return (env(name) ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
}
|
|
|
|
function emit(payload) {
|
|
process.stderr.write(JSON.stringify({
|
|
event: "artifact-catalog-restore",
|
|
source: "scripts/ci/restore-artifact-catalog.mjs",
|
|
at: new Date().toISOString(),
|
|
durationMs: Date.now() - startedAt,
|
|
catalogPath,
|
|
gitopsBranch,
|
|
...payload
|
|
}) + "\n");
|
|
}
|
|
|
|
function git(args, options = {}) {
|
|
return execFileSync("git", args, {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
stdio: options.capture === false ? ["ignore", "ignore", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
timeout: options.timeoutMs ?? 45_000
|
|
});
|
|
}
|
|
|
|
function gitProbe(args, options = {}) {
|
|
const result = spawnSync("git", args, {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
timeout: options.timeoutMs ?? 45_000
|
|
});
|
|
if (result.error) throw result.error;
|
|
return {
|
|
status: result.status ?? 1,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? ""
|
|
};
|
|
}
|
|
|
|
const catalogPath = env("HWLAB_CATALOG_PATH");
|
|
const gitopsBranch = env("HWLAB_GITOPS_BRANCH");
|
|
const remote = env("HWLAB_GIT_READ_URL") ?? env("HWLAB_GIT_URL");
|
|
const selectedServices = csvEnv("HWLAB_SERVICES");
|
|
|
|
if (!catalogPath || !gitopsBranch || !remote || selectedServices.length === 0) {
|
|
emit({
|
|
status: "failed",
|
|
reason: "required-input-missing",
|
|
hasCatalogPath: Boolean(catalogPath),
|
|
hasGitopsBranch: Boolean(gitopsBranch),
|
|
hasRemote: Boolean(remote),
|
|
selectedServiceCount: selectedServices.length
|
|
});
|
|
process.exit(2);
|
|
}
|
|
|
|
try {
|
|
const selectedServiceCoverageInput = uniqueSorted(selectedServices);
|
|
if (selectedServiceCoverageInput.length !== selectedServices.length) {
|
|
throw new Error("selected services contain duplicate service ids");
|
|
}
|
|
const branchProbe = gitProbe(["ls-remote", "--exit-code", "--heads", remote, `refs/heads/${gitopsBranch}`], { timeoutMs: 45_000 });
|
|
if (branchProbe.status === 2) {
|
|
restoreBootstrapOrMissing({ reason: "gitops-branch-missing", selectedServices: selectedServiceCoverageInput });
|
|
process.exit(0);
|
|
}
|
|
if (branchProbe.status !== 0) {
|
|
throw new Error(`gitops branch probe failed: ${boundedError(branchProbe.stderr)}`);
|
|
}
|
|
git(["fetch", "--depth=1", remote, `refs/heads/${gitopsBranch}`], { capture: false, timeoutMs: 60_000 });
|
|
const gitopsCommitId = git(["rev-parse", "FETCH_HEAD"], { timeoutMs: 15_000 }).trim();
|
|
const catalogProbe = gitProbe(["ls-tree", "-z", "--full-tree", "--name-only", "FETCH_HEAD", "--", catalogPath], { timeoutMs: 15_000 });
|
|
if (catalogProbe.status !== 0) {
|
|
throw new Error(`gitops catalog tree probe failed: ${boundedError(catalogProbe.stderr)}`);
|
|
}
|
|
if (catalogProbe.stdout.length === 0) {
|
|
restoreBootstrapOrMissing({ reason: "gitops-catalog-missing", selectedServices: selectedServiceCoverageInput });
|
|
process.exit(0);
|
|
}
|
|
const content = git(["show", `FETCH_HEAD:${catalogPath}`], { timeoutMs: 30_000 });
|
|
const parsed = JSON.parse(content);
|
|
if (!Array.isArray(parsed?.services) || parsed.services.length === 0) {
|
|
throw new Error("gitops catalog has no services");
|
|
}
|
|
const coverage = artifactCatalogServiceCoverage(parsed, selectedServiceCoverageInput);
|
|
const authorityPath = artifactCatalogAuthorityPath(catalogPath);
|
|
const authority = createHydratedArtifactCatalogAuthority({
|
|
catalog: parsed,
|
|
catalogPath,
|
|
gitopsBranch,
|
|
gitopsCommitId,
|
|
selectedServices: selectedServiceCoverageInput
|
|
});
|
|
mkdirSync(dirname(catalogPath), { recursive: true });
|
|
writeFileSync(catalogPath, JSON.stringify(parsed, null, 2) + "\n");
|
|
writeFileSync(authorityPath, JSON.stringify(authority, null, 2) + "\n");
|
|
emit({
|
|
status: "hydrated",
|
|
reason: "gitops-catalog",
|
|
authority: authority.authority,
|
|
authorityPath,
|
|
gitopsCommitId,
|
|
catalogSourceCommitId: authority.catalogSourceCommitId,
|
|
catalogSha256: authority.catalogSha256,
|
|
serviceCount: parsed.services.length,
|
|
selectedServiceCount: selectedServices.length,
|
|
coverage,
|
|
bytes: Buffer.byteLength(content)
|
|
});
|
|
} catch (error) {
|
|
emit({
|
|
status: "failed",
|
|
reason: "gitops-catalog-unavailable",
|
|
error: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500)
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
function restoreBootstrapOrMissing({ reason, selectedServices }) {
|
|
const authorityPath = artifactCatalogAuthorityPath(catalogPath);
|
|
if (!existsSync(catalogPath)) {
|
|
if (existsSync(authorityPath)) throw new Error(`artifact catalog authority exists without catalog ${catalogPath}`);
|
|
emit({
|
|
status: "missing",
|
|
reason,
|
|
authority: "none",
|
|
selectedServiceCount: selectedServices.length
|
|
});
|
|
return;
|
|
}
|
|
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
const sourceCommitId = git(["rev-parse", "HEAD"], { timeoutMs: 15_000 }).trim();
|
|
validateBootstrapCatalog({ catalog, sourceCommitId, selectedServices });
|
|
const authority = createBootstrapArtifactCatalogAuthority({
|
|
catalog,
|
|
catalogPath,
|
|
sourceCommitId,
|
|
reason,
|
|
selectedServices
|
|
});
|
|
writeFileSync(authorityPath, JSON.stringify(authority, null, 2) + "\n");
|
|
emit({
|
|
status: "bootstrap",
|
|
reason,
|
|
authority: authority.authority,
|
|
authorityPath,
|
|
sourceCommitId,
|
|
serviceCount: authority.serviceCount,
|
|
catalogSha256: authority.catalogSha256,
|
|
coverage: authority.coverage
|
|
});
|
|
}
|
|
|
|
function uniqueSorted(values) {
|
|
return [...new Set(values)].sort();
|
|
}
|
|
|
|
function validateBootstrapCatalog({ catalog, sourceCommitId, selectedServices }) {
|
|
if (catalog?.catalogVersion !== "v1" || catalog?.kind !== "hwlab-artifact-catalog" || catalog?.artifactState !== "contract-skeleton" || catalog?.environment !== "v02" || catalog?.profile !== "v02") {
|
|
throw new Error(`existing source catalog is not an allowed v02 contract skeleton for ${catalogPath}`);
|
|
}
|
|
if (catalog?.publish?.sourceCommitId !== sourceCommitId) {
|
|
throw new Error(`v02 contract skeleton source commit does not match HEAD for ${catalogPath}`);
|
|
}
|
|
const coverage = artifactCatalogServiceCoverage(catalog, selectedServices);
|
|
if (coverage.status !== "exact") {
|
|
throw new Error(`v02 contract skeleton service coverage is ${coverage.status} for ${catalogPath}`);
|
|
}
|
|
const unsafe = catalog.services.filter((service) => (
|
|
service?.sourceCommitId !== sourceCommitId
|
|
|| service?.buildBackend !== "contract-skeleton"
|
|
|| service?.digest !== null
|
|
|| (service?.environmentDigest !== undefined && service?.environmentDigest !== null)
|
|
));
|
|
if (unsafe.length > 0) throw new Error(`v02 contract skeleton contains reusable artifacts for ${catalogPath}`);
|
|
}
|
|
|
|
function boundedError(value) {
|
|
const result = String(value ?? "").replace(/\s+/gu, " ").trim();
|
|
return result ? result.slice(0, 300) : "exit-without-stderr";
|
|
}
|