fix: YAML-first 治理 CI/CD target (#919)
* docs: specify cicd yaml target governance * fix: resolve cicd targets from yaml --------- Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// 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<string, unknown>;
|
||||
consumerCatalogRef: string;
|
||||
configSource: TargetConfigSource;
|
||||
}
|
||||
|
||||
interface CodedTargetConfig<T> {
|
||||
defaults: { targetId: string };
|
||||
targets: Record<string, T>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
return {
|
||||
configPath: target.configSource.configPath,
|
||||
targetPath: target.configSource.targetPath,
|
||||
defaultPath: target.configSource.defaultPath,
|
||||
defaulted: target.configSource.defaulted,
|
||||
};
|
||||
}
|
||||
|
||||
export function cicdTargetSummary(target: ResolvedCicdTarget): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>>, selection: string | null | undefined, path: string): { targetId: string; record: Record<string, unknown>; 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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown, path: string): Record<string, unknown> | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return asRecord(value, path);
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user