#!/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"; }