feat: expand ci artifact catalog
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user