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:
Lyon
2026-06-26 01:14:38 +08:00
committed by GitHub
parent 3777577df4
commit edfddd2445
35 changed files with 1079 additions and 181 deletions
+125
View File
@@ -0,0 +1,125 @@
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
// Responsibility: shared YAML configRef parsing and redacted reference summaries.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { rootPath } from "../config";
export interface ParsedConfigRef {
ref: string;
file: string;
fragment: string;
}
export interface ResolvedConfigRef {
ref: string;
file: string;
fragment: string;
value: unknown;
valueType: string;
present: true;
sha256: string;
summary: unknown;
}
export function parseConfigRef(ref: string, label = "configRef"): ParsedConfigRef {
const [file, fragment, extra] = ref.split("#");
if (extra !== undefined || file === undefined || fragment === undefined || file.length === 0 || fragment.length === 0) {
throw new Error(`${label} must use path/to/file.yaml#object.path syntax`);
}
if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) {
throw new Error(`${label} must reference a repo-relative config/*.yaml file without ..`);
}
if (!/^[A-Za-z0-9_.\-[\]]+$/u.test(fragment)) throw new Error(`${label} has an unsupported YAML path fragment`);
return { ref, file, fragment };
}
export function resolveConfigRef(ref: string, label = "configRef"): ResolvedConfigRef {
const parsed = parseConfigRef(ref, label);
const yaml = Bun.YAML.parse(readFileSync(rootPath(parsed.file), "utf8")) as unknown;
const value = valueAtPath(yaml, parsed.fragment, `${parsed.file}#${parsed.fragment}`);
const serialized = stableSerialize(value);
return {
...parsed,
value,
valueType: valueType(value),
present: true,
sha256: createHash("sha256").update(serialized).digest("hex"),
summary: summarizeConfigValue(value),
};
}
export function resolveConfigRefString(ref: string, label = "configRef"): string {
const resolved = resolveConfigRef(ref, label);
if (typeof resolved.value !== "string" || resolved.value.length === 0) throw new Error(`${resolved.file}#${resolved.fragment} must resolve to a non-empty string`);
return resolved.value;
}
export function configRefSummary(ref: string, label = "configRef"): Record<string, unknown> {
const resolved = resolveConfigRef(ref, label);
return {
ref: resolved.ref,
file: resolved.file,
path: resolved.fragment,
present: resolved.present,
valueType: resolved.valueType,
sha256: resolved.sha256,
summary: resolved.summary,
};
}
export function configRefGraph(refs: Array<{ id: string; ref: string }>): Array<Record<string, unknown>> {
return refs.map((item) => ({ id: item.id, ...configRefSummary(item.ref, item.id) }));
}
function valueAtPath(value: unknown, fragment: string, label: string): unknown {
let current = value;
for (const segment of fragment.split(".")) {
if (segment.length === 0) throw new Error(`${label} has an empty path segment`);
const parts = [...segment.matchAll(/([A-Za-z0-9_-]+)|\[(\d+)\]/gu)];
if (parts.length === 0 || parts.map((match) => match[0]).join("") !== segment) throw new Error(`${label} has unsupported segment ${segment}`);
for (const part of parts) {
const key = part[1];
const index = part[2];
if (key !== undefined) {
if (typeof current !== "object" || current === null || Array.isArray(current)) throw new Error(`${label} segment ${key} does not resolve through an object`);
const record = current as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(record, key)) throw new Error(`${label} is missing segment ${key}`);
current = record[key];
} else if (index !== undefined) {
if (!Array.isArray(current)) throw new Error(`${label} segment [${index}] does not resolve through an array`);
const parsed = Number(index);
if (current[parsed] === undefined) throw new Error(`${label} is missing array item [${index}]`);
current = current[parsed];
}
}
}
return current;
}
function valueType(value: unknown): string {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
function summarizeConfigValue(value: unknown): unknown {
if (typeof value === "string") return value.length > 160 ? `${value.slice(0, 157)}...` : value;
if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
if (Array.isArray(value)) return { kind: "array", count: value.length };
if (typeof value === "object") {
const keys = Object.keys(value as Record<string, unknown>);
return { kind: "object", keys: keys.slice(0, 12), omittedKeys: Math.max(0, keys.length - 12) };
}
return String(value);
}
function stableSerialize(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
if (typeof value === "object" && value !== null) {
return `{${Object.keys(value as Record<string, unknown>)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableSerialize((value as Record<string, unknown>)[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
+236
View File
@@ -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;
}