Files
pikasTech-unidesk/scripts/src/ci-catalog.ts
T
2026-05-20 01:31:03 +00:00

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,
};
}