267 lines
12 KiB
TypeScript
267 lines
12 KiB
TypeScript
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<string, string>;
|
|
};
|
|
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error(`${name} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, key: string, path: string): string[] | undefined {
|
|
const value = obj[key];
|
|
if (value === undefined) return undefined;
|
|
return stringArrayField(obj, key, path);
|
|
}
|
|
|
|
function optionalBooleanField(obj: Record<string, unknown>, 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<string, unknown>, key: string, path: string): Record<string, string> {
|
|
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<string, string>;
|
|
}
|
|
|
|
function validateSourceBuildArtifact(item: Record<string, unknown>, 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<string, unknown>, 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<string>();
|
|
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,
|
|
};
|
|
}
|