import { existsSync, readFileSync } from "node:fs"; import { rootPath } from "./config"; export type CiArtifactStatus = "supported" | "blocked"; export type CiCatalogArtifact = CiSourceBuildCatalogArtifact | CiUpstreamImageCatalogArtifact; export interface CiCatalog { schemaVersion: number; kind: "ci-artifact-catalog"; purpose: string; summaryContract: { requiredOnSuccess: string[]; fieldSemantics: Record; }; defaults: { registry: string; tagTemplate: string; mutableTagsAllowed: boolean; runtimeFieldsForbidden: string[]; }; artifacts: CiCatalogArtifact[]; } export interface CiSourceBuildCatalogArtifact { serviceId: string; kind: "source-build"; status: CiArtifactStatus; producer: "ci publish-backend-core" | "ci publish-user-service"; source: { repo: string; dockerfile: string; root?: string; }; image: { repository: string; }; notes?: string; blockedReason?: string; } export interface CiUpstreamImageCatalogArtifact { serviceId: string; kind: "upstream-image"; status: "blocked"; producer: "ci publish-user-service"; upstream: { imageRef: string; digestRef: string; sourceRepo?: string; sourceRevision?: string; mirrorRepository: string; mirrorTag: string; mirrorDigestRef: string; }; notes?: string; blockedReason: string; } let cachedCatalog: CiCatalog | null = null; function asRecord(value: unknown, name: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`${name} must be an object`); } return value as Record; } function stringField(obj: Record, key: string, path: string): string { const value = obj[key]; if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); return value; } function stringArrayField(obj: Record, key: string, path: string): string[] { const value = obj[key]; if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) { throw new Error(`${path}.${key} must be an array of non-empty strings`); } return value as string[]; } function optionalStringField(obj: Record, key: string, path: string): string | undefined { const value = obj[key]; if (value === undefined) return undefined; if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); return value; } function optionalStringArrayField(obj: Record, key: string, path: string): string[] | undefined { const value = obj[key]; if (value === undefined) return undefined; return stringArrayField(obj, key, path); } function optionalBooleanField(obj: Record, key: string, path: string): boolean | undefined { const value = obj[key]; if (value === undefined) return undefined; if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`); return value; } function requiredCatalogPath(value: string, label: string): string { if (value.length === 0 || value.startsWith("/") || value.includes("\0") || value.split("/").includes("..")) { throw new Error(`${label} must be a repo-relative path`); } return value; } function requiredImageRepository(value: string, label: string): string { if (value.length === 0 || value.startsWith("/") || value.includes("..") || value.includes(":") || value.includes("@") || value.includes("latest") || !/^[a-z0-9._/-]+$/u.test(value)) { throw new Error(`${label} must be a registry repository path without registry host, tag, digest, latest, or uppercase characters`); } return value; } function stringRecordField(obj: Record, key: string, path: string): Record { const value = asRecord(obj[key], `${path}.${key}`); for (const [entryKey, entryValue] of Object.entries(value)) { if (typeof entryValue !== "string" || entryValue.length === 0) { throw new Error(`${path}.${key}.${entryKey} must be a non-empty string`); } } return value as Record; } function validateSourceBuildArtifact(item: Record, index: number): CiSourceBuildCatalogArtifact { const path = `artifacts[${index}]`; const source = asRecord(item.source, `${path}.source`); const image = asRecord(item.image, `${path}.image`); const status = stringField(item, "status", path); if (status !== "supported" && status !== "blocked") throw new Error(`${path}.status must be supported or blocked`); const producer = stringField(item, "producer", path); if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service") { throw new Error(`${path}.producer must be ci publish-backend-core or ci publish-user-service`); } const artifact: CiSourceBuildCatalogArtifact = { serviceId: stringField(item, "serviceId", path), kind: "source-build", status, producer, source: { repo: stringField(source, "repo", `${path}.source`), dockerfile: requiredCatalogPath(stringField(source, "dockerfile", `${path}.source`), `${path}.source.dockerfile`), ...(source.root === undefined ? {} : { root: requiredCatalogPath(stringField(source, "root", `${path}.source`), `${path}.source.root`) }), }, image: { repository: requiredImageRepository(stringField(image, "repository", `${path}.image`), `${path}.image.repository`), }, ...(optionalStringField(item, "notes", path) === undefined ? {} : { notes: optionalStringField(item, "notes", path) }), ...(optionalStringField(item, "blockedReason", path) === undefined ? {} : { blockedReason: optionalStringField(item, "blockedReason", path) }), }; if (artifact.status === "blocked" && artifact.blockedReason === undefined) { throw new Error(`${path}.blockedReason is required when status=blocked`); } if (artifact.status === "supported" && artifact.blockedReason !== undefined) { throw new Error(`${path}.blockedReason is only allowed when status=blocked`); } return artifact; } function validateUpstreamImageArtifact(item: Record, index: number): CiUpstreamImageCatalogArtifact { const path = `artifacts[${index}]`; const upstream = asRecord(item.upstream, `${path}.upstream`); const producer = stringField(item, "producer", path); if (producer !== "ci publish-user-service") throw new Error(`${path}.producer must be ci publish-user-service`); const artifact: CiUpstreamImageCatalogArtifact = { serviceId: stringField(item, "serviceId", path), kind: "upstream-image", status: "blocked", producer, upstream: { imageRef: stringField(upstream, "imageRef", `${path}.upstream`), digestRef: stringField(upstream, "digestRef", `${path}.upstream`), ...(optionalStringField(upstream, "sourceRepo", `${path}.upstream`) === undefined ? {} : { sourceRepo: optionalStringField(upstream, "sourceRepo", `${path}.upstream`) }), ...(optionalStringField(upstream, "sourceRevision", `${path}.upstream`) === undefined ? {} : { sourceRevision: optionalStringField(upstream, "sourceRevision", `${path}.upstream`) }), mirrorRepository: requiredImageRepository(stringField(upstream, "mirrorRepository", `${path}.upstream`), `${path}.upstream.mirrorRepository`), mirrorTag: stringField(upstream, "mirrorTag", `${path}.upstream`), mirrorDigestRef: stringField(upstream, "mirrorDigestRef", `${path}.upstream`), }, blockedReason: stringField(item, "blockedReason", path), ...(optionalStringField(item, "notes", path) === undefined ? {} : { notes: optionalStringField(item, "notes", path) }), }; return artifact; } function validateCatalogArtifact(item: unknown, index: number): CiCatalogArtifact { const record = asRecord(item, `artifacts[${index}]`); const kind = stringField(record, "kind", `artifacts[${index}]`); if (kind === "source-build") return validateSourceBuildArtifact(record, index); if (kind === "upstream-image") return validateUpstreamImageArtifact(record, index); throw new Error(`artifacts[${index}].kind must be source-build or upstream-image`); } function assertUniqueServiceIds(artifacts: CiCatalogArtifact[]): void { const seen = new Set(); for (const artifact of artifacts) { if (seen.has(artifact.serviceId)) throw new Error(`CI.json.artifacts contains duplicate serviceId: ${artifact.serviceId}`); seen.add(artifact.serviceId); } } export function loadCiCatalog(): CiCatalog { if (cachedCatalog !== null) return cachedCatalog; const path = rootPath("CI.json"); if (!existsSync(path)) throw new Error(`CI.json not found at ${path}`); const parsed = asRecord(JSON.parse(readFileSync(path, "utf8")) as unknown, "CI.json"); const schemaVersion = parsed.schemaVersion; if (schemaVersion !== 2) throw new Error("CI.json schemaVersion must be 2"); const kind = stringField(parsed, "kind", "CI.json"); if (kind !== "ci-artifact-catalog") throw new Error("CI.json kind must be ci-artifact-catalog"); const summaryContract = asRecord(parsed.summaryContract, "CI.json.summaryContract"); const defaults = asRecord(parsed.defaults, "CI.json.defaults"); const artifacts = Array.isArray(parsed.artifacts) ? parsed.artifacts.map((item, index) => validateCatalogArtifact(item, index)) : []; if (artifacts.length === 0) throw new Error("CI.json.artifacts must not be empty"); assertUniqueServiceIds(artifacts); cachedCatalog = { schemaVersion, kind, purpose: stringField(parsed, "purpose", "CI.json"), summaryContract: { requiredOnSuccess: stringArrayField(summaryContract, "requiredOnSuccess", "CI.json.summaryContract"), fieldSemantics: stringRecordField(summaryContract, "fieldSemantics", "CI.json.summaryContract"), }, defaults: { registry: stringField(defaults, "registry", "CI.json.defaults"), tagTemplate: stringField(defaults, "tagTemplate", "CI.json.defaults"), mutableTagsAllowed: optionalBooleanField(defaults, "mutableTagsAllowed", "CI.json.defaults") ?? false, runtimeFieldsForbidden: optionalStringArrayField(defaults, "runtimeFieldsForbidden", "CI.json.defaults") ?? [], }, artifacts, }; return cachedCatalog; } export function findCiCatalogArtifact(serviceId: string): CiCatalogArtifact | null { return loadCiCatalog().artifacts.find((artifact) => artifact.serviceId === serviceId) ?? null; } export function supportedSourceBuildArtifactIds(): string[] { return loadCiCatalog().artifacts .filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported") .map((artifact) => artifact.serviceId); } export function blockedCatalogArtifactIds(): string[] { return loadCiCatalog().artifacts.filter((artifact) => artifact.status === "blocked").map((artifact) => artifact.serviceId); } export function catalogSummary(): { totalArtifacts: number; sourceBuildArtifacts: number; supportedSourceBuildArtifacts: number; upstreamImageArtifacts: number; blockedArtifacts: number; } { const catalog = loadCiCatalog(); return { totalArtifacts: catalog.artifacts.length, sourceBuildArtifacts: catalog.artifacts.filter((artifact) => artifact.kind === "source-build").length, supportedSourceBuildArtifacts: catalog.artifacts.filter((artifact) => artifact.kind === "source-build" && artifact.status === "supported").length, upstreamImageArtifacts: catalog.artifacts.filter((artifact) => artifact.kind === "upstream-image").length, blockedArtifacts: catalog.artifacts.filter((artifact) => artifact.status === "blocked").length, }; }