213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
// SPEC: PJ2026-01060703 CI/CD branch follower reuse config.
|
|
// Responsibility: shared parser for runtime/env reuse declarations stored in source repos.
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
export const RUNTIME_REUSE_CONFIG_PATH = "gitops/reuse.ymal";
|
|
|
|
export interface RuntimeReuseConfig {
|
|
ok: boolean;
|
|
present: boolean;
|
|
path: string;
|
|
sourceCommit: string | null;
|
|
stageRef: string | null;
|
|
sha256: string | null;
|
|
apiVersion: string | null;
|
|
kind: string | null;
|
|
serviceCount: number;
|
|
services: RuntimeReuseService[];
|
|
errors: string[];
|
|
valuesRedacted: true;
|
|
}
|
|
|
|
export interface RuntimeReuseService {
|
|
id: string;
|
|
runtimeReuse: {
|
|
enabled: boolean | null;
|
|
codeIdentityPaths: string[];
|
|
envIdentityPaths: string[];
|
|
} | null;
|
|
envReuse: {
|
|
enabled: boolean | null;
|
|
mode: string | null;
|
|
nodeDepsPath: string | null;
|
|
envIdentityFiles: string[];
|
|
buildArgs: Record<string, string>;
|
|
} | null;
|
|
}
|
|
|
|
export function parseRuntimeReuseConfig(text: string, source: { sourceCommit: string | null; stageRef: string | null }): RuntimeReuseConfig {
|
|
const errors: string[] = [];
|
|
let parsed: unknown = null;
|
|
try {
|
|
parsed = Bun.YAML.parse(text) as unknown;
|
|
} catch (error) {
|
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH} YAML parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
const root = asOptionalRecord(parsed);
|
|
if (root === null) errors.push(`${RUNTIME_REUSE_CONFIG_PATH} must be a YAML object`);
|
|
const spec = asOptionalRecord(root?.spec) ?? root ?? {};
|
|
const services = parseServices(spec, errors);
|
|
if (services.length === 0) errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.services must declare at least one service`);
|
|
const kind = stringOrNull(root?.kind);
|
|
if (kind !== null && kind !== "RuntimeReuseConfig") errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.kind must be RuntimeReuseConfig`);
|
|
return {
|
|
ok: errors.length === 0,
|
|
present: true,
|
|
path: RUNTIME_REUSE_CONFIG_PATH,
|
|
sourceCommit: source.sourceCommit,
|
|
stageRef: source.stageRef,
|
|
sha256: createHash("sha256").update(text).digest("hex"),
|
|
apiVersion: stringOrNull(root?.apiVersion),
|
|
kind,
|
|
serviceCount: services.length,
|
|
services,
|
|
errors,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
export function missingRuntimeReuseConfig(source: { sourceCommit: string | null; stageRef: string | null }, reason: string): RuntimeReuseConfig {
|
|
return {
|
|
ok: false,
|
|
present: false,
|
|
path: RUNTIME_REUSE_CONFIG_PATH,
|
|
sourceCommit: source.sourceCommit,
|
|
stageRef: source.stageRef,
|
|
sha256: null,
|
|
apiVersion: null,
|
|
kind: null,
|
|
serviceCount: 0,
|
|
services: [],
|
|
errors: [reason],
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
export function invalidRuntimeReuseConfig(config: RuntimeReuseConfig, reason: string): RuntimeReuseConfig {
|
|
return {
|
|
...config,
|
|
ok: false,
|
|
errors: [reason, ...config.errors].slice(0, 6),
|
|
};
|
|
}
|
|
|
|
export function summarizeRuntimeReuseConfig(config: RuntimeReuseConfig | null): Record<string, unknown> | null {
|
|
if (config === null) return null;
|
|
return {
|
|
ok: config.ok,
|
|
present: config.present,
|
|
path: config.path,
|
|
sourceCommit: config.sourceCommit,
|
|
stageRef: config.stageRef,
|
|
sha256: config.sha256,
|
|
serviceCount: config.serviceCount,
|
|
serviceIds: config.services.map((service) => service.id).sort(),
|
|
errors: config.errors.slice(0, 5),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
export function runtimeReuseService(config: RuntimeReuseConfig | null, ids: readonly string[]): RuntimeReuseService | null {
|
|
if (config === null) return null;
|
|
const wanted = new Set(ids);
|
|
return config.services.find((service) => wanted.has(service.id)) ?? null;
|
|
}
|
|
|
|
export function requiredReuseServiceError(reuseConfig: RuntimeReuseConfig, ids: readonly string[], mode: "runtimeReuse" | "envReuse"): string | null {
|
|
const service = runtimeReuseService(reuseConfig, ids);
|
|
if (service === null) return `${RUNTIME_REUSE_CONFIG_PATH} must declare service ${ids.join("|")}`;
|
|
if (mode === "runtimeReuse") {
|
|
if (service.runtimeReuse === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} must declare runtimeReuse`;
|
|
if (service.runtimeReuse.enabled === false) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} runtimeReuse is disabled`;
|
|
if (service.runtimeReuse.codeIdentityPaths.length === 0 && service.runtimeReuse.envIdentityPaths.length === 0) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} runtimeReuse must declare codeIdentity or envIdentity paths`;
|
|
return null;
|
|
}
|
|
if (service.envReuse === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} must declare envReuse`;
|
|
if (service.envReuse.enabled === false) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} envReuse is disabled`;
|
|
if (service.envReuse.envIdentityFiles.length === 0 && service.envReuse.nodeDepsPath === null) return `${RUNTIME_REUSE_CONFIG_PATH} service ${service.id} envReuse must declare envIdentityFiles or nodeDepsPath`;
|
|
return null;
|
|
}
|
|
|
|
function parseServices(spec: Record<string, unknown>, errors: string[]): RuntimeReuseService[] {
|
|
const raw = spec.services;
|
|
const items = Array.isArray(raw)
|
|
? raw.map((value, index) => ({ id: stringOrNull(asOptionalRecord(value)?.id) ?? String(index), value, path: `services[${index}]` }))
|
|
: Object.entries(asOptionalRecord(raw) ?? {}).map(([id, value]) => ({ id, value, path: `services.${id}` }));
|
|
return items.flatMap(({ id, value, path }) => {
|
|
const record = asOptionalRecord(value);
|
|
if (record === null) {
|
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path} must be an object`);
|
|
return [];
|
|
}
|
|
const serviceId = stringOrNull(record.id) ?? id;
|
|
if (serviceId.length === 0) errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}.id is required`);
|
|
return [{
|
|
id: serviceId,
|
|
runtimeReuse: parseRuntimeReuse(asOptionalRecord(record.runtimeReuse), `${path}.runtimeReuse`, errors),
|
|
envReuse: parseEnvReuse(asOptionalRecord(record.envReuse), `${path}.envReuse`, errors),
|
|
}];
|
|
});
|
|
}
|
|
|
|
function parseRuntimeReuse(record: Record<string, unknown> | null, path: string, errors: string[]): RuntimeReuseService["runtimeReuse"] {
|
|
if (record === null) return null;
|
|
return {
|
|
enabled: booleanOrNull(record.enabled),
|
|
codeIdentityPaths: safePathArray(asOptionalRecord(record.codeIdentity)?.paths ?? record.codeIdentityPaths, `${path}.codeIdentity.paths`, errors),
|
|
envIdentityPaths: safePathArray(asOptionalRecord(record.envIdentity)?.paths ?? record.envIdentityPaths, `${path}.envIdentity.paths`, errors),
|
|
};
|
|
}
|
|
|
|
function parseEnvReuse(record: Record<string, unknown> | null, path: string, errors: string[]): RuntimeReuseService["envReuse"] {
|
|
if (record === null) return null;
|
|
return {
|
|
enabled: booleanOrNull(record.enabled),
|
|
mode: stringOrNull(record.mode),
|
|
nodeDepsPath: stringOrNull(record.nodeDepsPath),
|
|
envIdentityFiles: safePathArray(record.envIdentityFiles ?? asOptionalRecord(record.envIdentity)?.paths, `${path}.envIdentityFiles`, errors),
|
|
buildArgs: stringRecord(asOptionalRecord(record.buildArgs), `${path}.buildArgs`, errors),
|
|
};
|
|
}
|
|
|
|
function safePathArray(value: unknown, path: string, errors: string[]): string[] {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Array.isArray(value)) {
|
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path} must be a string array`);
|
|
return [];
|
|
}
|
|
return value.flatMap((item, index) => {
|
|
if (typeof item !== "string" || item.length === 0) {
|
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}[${index}] must be a non-empty string`);
|
|
return [];
|
|
}
|
|
if (item.startsWith("/") || item.includes("..")) {
|
|
errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}[${index}] must be a relative path without ..`);
|
|
return [];
|
|
}
|
|
return [item];
|
|
});
|
|
}
|
|
|
|
function stringRecord(record: Record<string, unknown> | null, path: string, errors: string[]): Record<string, string> {
|
|
if (record === null) return {};
|
|
const out: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (typeof value !== "string") errors.push(`${RUNTIME_REUSE_CONFIG_PATH}.spec.${path}.${key} must be a string`);
|
|
else out[key] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function asOptionalRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function stringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function booleanOrNull(value: unknown): boolean | null {
|
|
return typeof value === "boolean" ? value : null;
|
|
}
|