// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets. // Responsibility: YAML-backed target resolvers shared by CI/CD control-plane commands. import { readFileSync } from "node:fs"; import { rootPath } from "../config"; import { configRefSummary } from "./config-refs"; export const CICD_TARGETS_CONFIG_PATH = "config/cicd/targets.yaml"; export const ARTIFACT_REGISTRY_CONFIG_PATH = "config/artifact-registry.yaml"; export interface TargetConfigSource { configPath: string; targetPath: string; defaultPath: string; defaulted: boolean; } export interface ResolvedCicdTarget { targetId: string; providerId: string; kubeRoute: string; kubeconfig: string; hostCwd: string; homeDir: string; pipelineManifest: string; codeQueueImage: string; guardName: string; requiredNodeName?: string; requiredNodeLabel?: { key: string; value: string }; artifactRegistryConfigRef?: string; configSource: TargetConfigSource; } export interface ResolvedArtifactRegistryTarget { targetId: string; providerId: string; mode: string; host: string; port: number; endpoint: string; image: string; repositoryPrefix: string; baseDir: string; storageDir: string; unitName: string; composeProject: string; serviceName: string; containerName: string; timeoutMs: number; sourceRepo: string; consumerTarget: Record; consumerCatalogRef: string; configSource: TargetConfigSource; } interface CodedTargetConfig { defaults: { targetId: string }; targets: Record; } export function resolveCicdTarget(selection: string | null | undefined): ResolvedCicdTarget { const config = readTargetConfig(CICD_TARGETS_CONFIG_PATH, "UnideskCicdTargets"); const { targetId, record, defaulted } = selectTarget(config, selection, CICD_TARGETS_CONFIG_PATH); const label = `targets.${targetId}`; const requiredNodeLabel = optionalRecord(record.requiredNodeLabel, `${label}.requiredNodeLabel`); return { targetId, providerId: stringField(record, "providerId", label), kubeRoute: stringField(record, "kubeRoute", label), kubeconfig: absolutePathField(record, "kubeconfig", label), hostCwd: absolutePathField(record, "hostCwd", label), homeDir: absolutePathField(record, "homeDir", label), pipelineManifest: stringField(record, "pipelineManifest", label), codeQueueImage: stringField(record, "codeQueueImage", label), guardName: stringField(record, "guardName", label), ...(record.requiredNodeName === undefined ? {} : { requiredNodeName: stringField(record, "requiredNodeName", label) }), ...(requiredNodeLabel === undefined ? {} : { requiredNodeLabel: { key: stringField(requiredNodeLabel, "key", `${label}.requiredNodeLabel`), value: stringField(requiredNodeLabel, "value", `${label}.requiredNodeLabel`) } }), ...(record.artifactRegistry === undefined ? {} : { artifactRegistryConfigRef: stringField(asRecord(record.artifactRegistry, `${label}.artifactRegistry`), "configRef", `${label}.artifactRegistry`) }), configSource: configSource(CICD_TARGETS_CONFIG_PATH, targetId, defaulted), }; } export function resolveArtifactRegistryTarget(selection: string | null | undefined): ResolvedArtifactRegistryTarget { const config = readTargetConfig(ARTIFACT_REGISTRY_CONFIG_PATH, "UnideskArtifactRegistry"); const { targetId, record, defaulted } = selectTarget(config, selection, ARTIFACT_REGISTRY_CONFIG_PATH); const label = `targets.${targetId}`; const registry = asRecord(record.registry, `${label}.registry`); const runtime = asRecord(record.runtime, `${label}.runtime`); const source = asRecord(record.source, `${label}.source`); const consumers = asRecord(record.consumers, `${label}.consumers`); return { targetId, providerId: stringField(record, "providerId", label), mode: stringField(record, "mode", label), host: stringField(registry, "host", `${label}.registry`), port: portField(registry, "port", `${label}.registry`), endpoint: stringField(registry, "endpoint", `${label}.registry`), image: stringField(registry, "image", `${label}.registry`), repositoryPrefix: stringField(registry, "repositoryPrefix", `${label}.registry`), baseDir: absolutePathField(runtime, "baseDir", `${label}.runtime`), storageDir: absolutePathField(runtime, "storageDir", `${label}.runtime`), unitName: stringField(runtime, "unitName", `${label}.runtime`), composeProject: stringField(runtime, "composeProject", `${label}.runtime`), serviceName: stringField(runtime, "serviceName", `${label}.runtime`), containerName: stringField(runtime, "containerName", `${label}.runtime`), timeoutMs: positiveIntegerField(runtime, "timeoutMs", `${label}.runtime`), sourceRepo: stringField(source, "repo", `${label}.source`), consumerTarget: asRecord(consumers.target, `${label}.consumers.target`), consumerCatalogRef: stringField(consumers, "catalogRef", `${label}.consumers`), configSource: configSource(ARTIFACT_REGISTRY_CONFIG_PATH, targetId, defaulted), }; } export function targetSourceSummary(target: { configSource: TargetConfigSource }): Record { return { configPath: target.configSource.configPath, targetPath: target.configSource.targetPath, defaultPath: target.configSource.defaultPath, defaulted: target.configSource.defaulted, }; } export function cicdTargetSummary(target: ResolvedCicdTarget): Record { return { targetId: target.targetId, providerId: target.providerId, kubeRoute: target.kubeRoute, kubeconfig: target.kubeconfig, hostCwd: target.hostCwd, homeDir: target.homeDir, pipelineManifest: target.pipelineManifest, codeQueueImage: target.codeQueueImage, guardName: target.guardName, requiredNodeName: target.requiredNodeName ?? null, requiredNodeLabel: target.requiredNodeLabel ?? null, artifactRegistryConfigRef: target.artifactRegistryConfigRef === undefined ? null : configRefSummary(target.artifactRegistryConfigRef, `${target.configSource.targetPath}.artifactRegistry.configRef`), source: targetSourceSummary(target), }; } export function artifactRegistryTargetSummary(target: ResolvedArtifactRegistryTarget): Record { return { targetId: target.targetId, providerId: target.providerId, mode: target.mode, registry: { image: target.image, host: target.host, port: target.port, endpoint: target.endpoint, repositoryPrefix: target.repositoryPrefix, }, runtime: { baseDir: target.baseDir, storageDir: target.storageDir, unitName: target.unitName, composeProject: target.composeProject, serviceName: target.serviceName, containerName: target.containerName, timeoutMs: target.timeoutMs, }, consumerTarget: target.consumerTarget, consumerCatalogRef: target.consumerCatalogRef, source: targetSourceSummary(target), }; } function readTargetConfig(path: string, expectedKind: string): CodedTargetConfig> { const parsed = Bun.YAML.parse(readFileSync(rootPath(path), "utf8")) as unknown; const root = asRecord(parsed, path); const version = root.version; if (version !== 1) throw new Error(`${path}.version must be 1`); if (root.kind !== expectedKind) throw new Error(`${path}.kind must be ${expectedKind}`); const defaults = asRecord(root.defaults, `${path}.defaults`); const targets = asRecord(root.targets, `${path}.targets`); const config = { defaults: { targetId: stringField(defaults, "targetId", `${path}.defaults`) }, targets: Object.fromEntries(Object.entries(targets).map(([key, value]) => [key, asRecord(value, `${path}.targets.${key}`)])), }; if (config.targets[config.defaults.targetId] === undefined) throw new Error(`${path}.defaults.targetId references missing target ${config.defaults.targetId}`); return config; } function selectTarget(config: CodedTargetConfig>, selection: string | null | undefined, path: string): { targetId: string; record: Record; defaulted: boolean } { const raw = selection === undefined || selection === null || selection.length === 0 ? config.defaults.targetId : selection; const targetId = Object.keys(config.targets).find((candidate) => candidate.toLowerCase() === raw.toLowerCase()) ?? Object.entries(config.targets).find(([, target]) => typeof target.providerId === "string" && target.providerId.toLowerCase() === raw.toLowerCase())?.[0]; if (targetId === undefined) throw new Error(`${path} has no target ${raw}; known targets: ${Object.keys(config.targets).join(", ")}`); return { targetId, record: config.targets[targetId], defaulted: raw === config.defaults.targetId && (selection === undefined || selection === null || selection.length === 0) }; } function configSource(path: string, targetId: string, defaulted: boolean): TargetConfigSource { return { configPath: path, targetPath: `${path}#targets.${targetId}`, defaultPath: `${path}#defaults.targetId`, defaulted, }; } function asRecord(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); return value as Record; } function optionalRecord(value: unknown, path: string): Record | undefined { if (value === undefined || value === null) return undefined; return asRecord(value, path); } function stringField(record: Record, key: string, path: string): string { const value = record[key]; if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`); return value.trim(); } function absolutePathField(record: Record, key: string, path: string): string { const value = stringField(record, key, path); if (!value.startsWith("/")) throw new Error(`${path}.${key} must be an absolute path`); return value.replace(/\/+$/u, ""); } function positiveIntegerField(record: Record, key: string, path: string): number { const value = record[key]; if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${path}.${key} must be a positive integer`); return Number(value); } function portField(record: Record, key: string, path: string): number { const value = positiveIntegerField(record, key, path); if (value > 65535) throw new Error(`${path}.${key} must be a TCP port`); return value; }