refactor: add node-scoped HWLAB lane CLI
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { rootPath } from "./config";
|
||||
|
||||
export type HwlabRuntimeLane = "v02" | "v03";
|
||||
|
||||
export interface HwlabRuntimeNodeSpec {
|
||||
readonly id: string;
|
||||
readonly route: string;
|
||||
readonly kubeRoute: string;
|
||||
readonly sourceWorkspace: string;
|
||||
readonly gitopsRoot: string;
|
||||
readonly networkProfileId: string;
|
||||
readonly downloadProfileId: string;
|
||||
}
|
||||
|
||||
export interface HwlabProxySpec {
|
||||
readonly http: string;
|
||||
readonly https: string;
|
||||
readonly all: string;
|
||||
readonly socks5?: string;
|
||||
readonly noProxy: readonly string[];
|
||||
}
|
||||
|
||||
export interface HwlabNetworkProfileSpec {
|
||||
readonly id: string;
|
||||
readonly proxy: HwlabProxySpec;
|
||||
readonly dockerBuildProxy: HwlabProxySpec;
|
||||
}
|
||||
|
||||
export interface HwlabDownloadProfileSpec {
|
||||
readonly id: string;
|
||||
readonly git: {
|
||||
readonly proxyMode: "inherit" | "direct" | "none";
|
||||
readonly retries: number;
|
||||
};
|
||||
readonly npm: {
|
||||
readonly registry: string;
|
||||
readonly retries: number;
|
||||
readonly fetchTimeoutSeconds: number;
|
||||
};
|
||||
readonly pip: {
|
||||
readonly indexUrl: string;
|
||||
readonly retries: number;
|
||||
readonly timeoutSeconds: number;
|
||||
};
|
||||
readonly docker: {
|
||||
readonly registryMirrors: readonly string[];
|
||||
readonly pullRetries: number;
|
||||
};
|
||||
readonly curl: {
|
||||
readonly retries: number;
|
||||
readonly connectTimeoutSeconds: number;
|
||||
readonly maxTimeSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HwlabRuntimeLaneSpec {
|
||||
readonly lane: HwlabRuntimeLane;
|
||||
readonly nodeId: string;
|
||||
readonly nodeRoute: string;
|
||||
readonly nodeKubeRoute: string;
|
||||
readonly gitopsRoot: string;
|
||||
readonly minor: number;
|
||||
readonly version: string;
|
||||
readonly sourceBranch: string;
|
||||
readonly workspace: string;
|
||||
readonly cicdRepo: string;
|
||||
readonly cicdRepoLock: string;
|
||||
readonly app: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly serviceAccountName: string;
|
||||
readonly controlPlaneFieldManager: string;
|
||||
readonly gitUrl: string;
|
||||
readonly gitReadUrl: string;
|
||||
readonly gitWriteUrl: string;
|
||||
readonly gitopsBranch: string;
|
||||
readonly catalogPath: string;
|
||||
readonly runtimePath: string;
|
||||
readonly runtimeNamespace: string;
|
||||
readonly runtimeRenderDir: string;
|
||||
readonly tektonDir: string;
|
||||
readonly argoApplicationFile: string;
|
||||
readonly registryPrefix: string;
|
||||
readonly baseImage: string;
|
||||
readonly serviceIds: readonly string[];
|
||||
readonly publicWebUrl: string;
|
||||
readonly publicApiUrl: string;
|
||||
readonly networkProfileId: string;
|
||||
readonly downloadProfileId: string;
|
||||
readonly networkProfile: HwlabNetworkProfileSpec;
|
||||
readonly downloadProfile: HwlabDownloadProfileSpec;
|
||||
}
|
||||
|
||||
export const HWLAB_NODE_LANE_CONFIG_PATH = "config/hwlab-node-lanes.yaml";
|
||||
|
||||
interface HwlabLaneConfig {
|
||||
readonly id: HwlabRuntimeLane;
|
||||
readonly node: string;
|
||||
readonly minor: number;
|
||||
readonly version: string;
|
||||
readonly sourceBranch: string;
|
||||
readonly workspace: string;
|
||||
readonly cicdRepo: string;
|
||||
readonly cicdRepoLock: string;
|
||||
readonly app: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly serviceAccountName: string;
|
||||
readonly controlPlaneFieldManager: string;
|
||||
readonly git: { readonly url: string; readonly readUrl: string; readonly writeUrl: string };
|
||||
readonly gitopsBranch: string;
|
||||
readonly catalogPath: string;
|
||||
readonly runtime: { readonly path: string; readonly namespace: string; readonly renderDir: string };
|
||||
readonly tektonDir: string;
|
||||
readonly argoApplicationFile: string;
|
||||
readonly registryPrefix: string;
|
||||
readonly baseImage: string;
|
||||
readonly serviceIds: readonly string[];
|
||||
readonly public: { readonly webUrl: string; readonly apiUrl: string };
|
||||
}
|
||||
|
||||
interface HwlabNodeLaneConfig {
|
||||
readonly requiredNoProxy: readonly string[];
|
||||
readonly nodes: Record<string, HwlabRuntimeNodeSpec>;
|
||||
readonly lanes: Record<HwlabRuntimeLane, HwlabLaneConfig>;
|
||||
readonly networkProfiles: Record<string, HwlabNetworkProfileSpec>;
|
||||
readonly downloadProfiles: Record<string, HwlabDownloadProfileSpec>;
|
||||
}
|
||||
|
||||
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 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 numberField(obj: Record<string, unknown>, key: string, path: string): number {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path}.${key} must be a positive integer`);
|
||||
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 sortedRecordEntries(value: unknown, path: string): Array<[string, Record<string, unknown>]> {
|
||||
return Object.entries(asRecord(value, path)).map(([key, item]) => [key, asRecord(item, `${path}.${key}`)]);
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function proxyConfig(raw: Record<string, unknown>, id: string, key: "proxy" | "dockerBuildProxy", requiredNoProxy: readonly string[]): HwlabProxySpec {
|
||||
const proxy = asRecord(raw[key], `networkProfiles.${id}.${key}`);
|
||||
return {
|
||||
http: stringField(proxy, "http", `networkProfiles.${id}.${key}`),
|
||||
https: stringField(proxy, "https", `networkProfiles.${id}.${key}`),
|
||||
all: stringField(proxy, "all", `networkProfiles.${id}.${key}`),
|
||||
socks5: optionalStringField(proxy, "socks5", `networkProfiles.${id}.${key}`),
|
||||
noProxy: unique([...stringArrayField(proxy, "noProxy", `networkProfiles.${id}.${key}`), ...requiredNoProxy]),
|
||||
};
|
||||
}
|
||||
|
||||
function networkProfileConfig(id: string, raw: Record<string, unknown>, requiredNoProxy: readonly string[]): HwlabNetworkProfileSpec {
|
||||
return {
|
||||
id,
|
||||
proxy: proxyConfig(raw, id, "proxy", requiredNoProxy),
|
||||
dockerBuildProxy: proxyConfig(raw, id, "dockerBuildProxy", requiredNoProxy),
|
||||
};
|
||||
}
|
||||
|
||||
function downloadProfileConfig(id: string, raw: Record<string, unknown>): HwlabDownloadProfileSpec {
|
||||
const git = asRecord(raw.git, `downloadProfiles.${id}.git`);
|
||||
const npm = asRecord(raw.npm, `downloadProfiles.${id}.npm`);
|
||||
const pip = asRecord(raw.pip, `downloadProfiles.${id}.pip`);
|
||||
const docker = asRecord(raw.docker, `downloadProfiles.${id}.docker`);
|
||||
const curl = asRecord(raw.curl, `downloadProfiles.${id}.curl`);
|
||||
const proxyMode = stringField(git, "proxyMode", `downloadProfiles.${id}.git`);
|
||||
if (proxyMode !== "inherit" && proxyMode !== "direct" && proxyMode !== "none") {
|
||||
throw new Error(`downloadProfiles.${id}.git.proxyMode must be inherit, direct, or none`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
git: { proxyMode, retries: numberField(git, "retries", `downloadProfiles.${id}.git`) },
|
||||
npm: {
|
||||
registry: stringField(npm, "registry", `downloadProfiles.${id}.npm`),
|
||||
retries: numberField(npm, "retries", `downloadProfiles.${id}.npm`),
|
||||
fetchTimeoutSeconds: numberField(npm, "fetchTimeoutSeconds", `downloadProfiles.${id}.npm`),
|
||||
},
|
||||
pip: {
|
||||
indexUrl: stringField(pip, "indexUrl", `downloadProfiles.${id}.pip`),
|
||||
retries: numberField(pip, "retries", `downloadProfiles.${id}.pip`),
|
||||
timeoutSeconds: numberField(pip, "timeoutSeconds", `downloadProfiles.${id}.pip`),
|
||||
},
|
||||
docker: {
|
||||
registryMirrors: stringArrayField(docker, "registryMirrors", `downloadProfiles.${id}.docker`),
|
||||
pullRetries: numberField(docker, "pullRetries", `downloadProfiles.${id}.docker`),
|
||||
},
|
||||
curl: {
|
||||
retries: numberField(curl, "retries", `downloadProfiles.${id}.curl`),
|
||||
connectTimeoutSeconds: numberField(curl, "connectTimeoutSeconds", `downloadProfiles.${id}.curl`),
|
||||
maxTimeSeconds: numberField(curl, "maxTimeSeconds", `downloadProfiles.${id}.curl`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nodeConfig(id: string, raw: Record<string, unknown>): HwlabRuntimeNodeSpec {
|
||||
return {
|
||||
id,
|
||||
route: stringField(raw, "route", `nodes.${id}`),
|
||||
kubeRoute: stringField(raw, "kubeRoute", `nodes.${id}`),
|
||||
sourceWorkspace: stringField(raw, "sourceWorkspace", `nodes.${id}`),
|
||||
gitopsRoot: stringField(raw, "gitopsRoot", `nodes.${id}`),
|
||||
networkProfileId: stringField(raw, "networkProfile", `nodes.${id}`),
|
||||
downloadProfileId: stringField(raw, "downloadProfile", `nodes.${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
function isSupportedLaneId(id: string): id is HwlabRuntimeLane {
|
||||
return id === "v02" || id === "v03";
|
||||
}
|
||||
|
||||
function laneConfig(id: HwlabRuntimeLane, raw: Record<string, unknown>): HwlabLaneConfig {
|
||||
const git = asRecord(raw.git, `lanes.${id}.git`);
|
||||
const runtime = asRecord(raw.runtime, `lanes.${id}.runtime`);
|
||||
const publicUrls = asRecord(raw.public, `lanes.${id}.public`);
|
||||
const minor = numberField(raw, "minor", `lanes.${id}`);
|
||||
const version = stringField(raw, "version", `lanes.${id}`);
|
||||
if (version !== `v0.${minor}`) throw new Error(`lanes.${id}.version must equal v0.${minor}`);
|
||||
return {
|
||||
id,
|
||||
node: stringField(raw, "node", `lanes.${id}`),
|
||||
minor,
|
||||
version,
|
||||
sourceBranch: stringField(raw, "sourceBranch", `lanes.${id}`),
|
||||
workspace: stringField(raw, "workspace", `lanes.${id}`),
|
||||
cicdRepo: stringField(raw, "cicdRepo", `lanes.${id}`),
|
||||
cicdRepoLock: stringField(raw, "cicdRepoLock", `lanes.${id}`),
|
||||
app: stringField(raw, "app", `lanes.${id}`),
|
||||
pipeline: stringField(raw, "pipeline", `lanes.${id}`),
|
||||
pipelineRunPrefix: stringField(raw, "pipelineRunPrefix", `lanes.${id}`),
|
||||
serviceAccountName: stringField(raw, "serviceAccountName", `lanes.${id}`),
|
||||
controlPlaneFieldManager: stringField(raw, "controlPlaneFieldManager", `lanes.${id}`),
|
||||
git: {
|
||||
url: stringField(git, "url", `lanes.${id}.git`),
|
||||
readUrl: stringField(git, "readUrl", `lanes.${id}.git`),
|
||||
writeUrl: stringField(git, "writeUrl", `lanes.${id}.git`),
|
||||
},
|
||||
gitopsBranch: stringField(raw, "gitopsBranch", `lanes.${id}`),
|
||||
catalogPath: stringField(raw, "catalogPath", `lanes.${id}`),
|
||||
runtime: {
|
||||
path: stringField(runtime, "path", `lanes.${id}.runtime`),
|
||||
namespace: stringField(runtime, "namespace", `lanes.${id}.runtime`),
|
||||
renderDir: stringField(runtime, "renderDir", `lanes.${id}.runtime`),
|
||||
},
|
||||
tektonDir: stringField(raw, "tektonDir", `lanes.${id}`),
|
||||
argoApplicationFile: stringField(raw, "argoApplicationFile", `lanes.${id}`),
|
||||
registryPrefix: stringField(raw, "registryPrefix", `lanes.${id}`),
|
||||
baseImage: stringField(raw, "baseImage", `lanes.${id}`),
|
||||
serviceIds: stringArrayField(raw, "serviceIds", `lanes.${id}`),
|
||||
public: {
|
||||
webUrl: stringField(publicUrls, "webUrl", `lanes.${id}.public`),
|
||||
apiUrl: stringField(publicUrls, "apiUrl", `lanes.${id}.public`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readHwlabNodeLaneConfig(): HwlabNodeLaneConfig {
|
||||
const path = rootPath(HWLAB_NODE_LANE_CONFIG_PATH);
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const parsed = asRecord(Bun.YAML.parse(raw) as unknown, HWLAB_NODE_LANE_CONFIG_PATH);
|
||||
const requiredNoProxy = stringArrayField(parsed, "requiredNoProxy", HWLAB_NODE_LANE_CONFIG_PATH);
|
||||
for (const required of ["hyueapi.com", ".hyueapi.com"]) {
|
||||
if (!requiredNoProxy.includes(required)) throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.requiredNoProxy must include ${required}`);
|
||||
}
|
||||
const nodes = Object.fromEntries(sortedRecordEntries(parsed.nodes, "nodes").map(([id, item]) => [id, nodeConfig(id, item)]));
|
||||
const networkProfiles = Object.fromEntries(
|
||||
sortedRecordEntries(parsed.networkProfiles, "networkProfiles").map(([id, item]) => [id, networkProfileConfig(id, item, requiredNoProxy)]),
|
||||
);
|
||||
const downloadProfiles = Object.fromEntries(
|
||||
sortedRecordEntries(parsed.downloadProfiles, "downloadProfiles").map(([id, item]) => [id, downloadProfileConfig(id, item)]),
|
||||
);
|
||||
const lanes = Object.fromEntries(sortedRecordEntries(parsed.lanes, "lanes").map(([id, item]) => {
|
||||
if (!isSupportedLaneId(id)) throw new Error(`lanes.${id} is not supported by this CLI build`);
|
||||
return [id, laneConfig(id, item)];
|
||||
})) as Record<HwlabRuntimeLane, HwlabLaneConfig>;
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (networkProfiles[node.networkProfileId] === undefined) throw new Error(`nodes.${node.id}.networkProfile references missing profile ${node.networkProfileId}`);
|
||||
if (downloadProfiles[node.downloadProfileId] === undefined) throw new Error(`nodes.${node.id}.downloadProfile references missing profile ${node.downloadProfileId}`);
|
||||
}
|
||||
for (const lane of Object.values(lanes)) {
|
||||
if (nodes[lane.node] === undefined) throw new Error(`lanes.${lane.id}.node references missing node ${lane.node}`);
|
||||
}
|
||||
return { requiredNoProxy, nodes, lanes, networkProfiles, downloadProfiles };
|
||||
}
|
||||
|
||||
const HWLAB_NODE_LANE_CONFIG = readHwlabNodeLaneConfig();
|
||||
|
||||
function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec {
|
||||
const node = HWLAB_NODE_LANE_CONFIG.nodes[config.node];
|
||||
const networkProfile = HWLAB_NODE_LANE_CONFIG.networkProfiles[node.networkProfileId];
|
||||
const downloadProfile = HWLAB_NODE_LANE_CONFIG.downloadProfiles[node.downloadProfileId];
|
||||
return {
|
||||
lane: config.id,
|
||||
nodeId: node.id,
|
||||
nodeRoute: node.route,
|
||||
nodeKubeRoute: node.kubeRoute,
|
||||
gitopsRoot: node.gitopsRoot,
|
||||
minor: config.minor,
|
||||
version: config.version,
|
||||
sourceBranch: config.sourceBranch,
|
||||
workspace: config.workspace,
|
||||
cicdRepo: config.cicdRepo,
|
||||
cicdRepoLock: config.cicdRepoLock,
|
||||
app: config.app,
|
||||
pipeline: config.pipeline,
|
||||
pipelineRunPrefix: config.pipelineRunPrefix,
|
||||
serviceAccountName: config.serviceAccountName,
|
||||
controlPlaneFieldManager: config.controlPlaneFieldManager,
|
||||
gitUrl: config.git.url,
|
||||
gitReadUrl: config.git.readUrl,
|
||||
gitWriteUrl: config.git.writeUrl,
|
||||
gitopsBranch: config.gitopsBranch,
|
||||
catalogPath: config.catalogPath,
|
||||
runtimePath: config.runtime.path,
|
||||
runtimeNamespace: config.runtime.namespace,
|
||||
runtimeRenderDir: config.runtime.renderDir,
|
||||
tektonDir: config.tektonDir,
|
||||
argoApplicationFile: config.argoApplicationFile,
|
||||
registryPrefix: config.registryPrefix,
|
||||
baseImage: config.baseImage,
|
||||
serviceIds: config.serviceIds,
|
||||
publicWebUrl: config.public.webUrl,
|
||||
publicApiUrl: config.public.apiUrl,
|
||||
networkProfileId: networkProfile.id,
|
||||
downloadProfileId: downloadProfile.id,
|
||||
networkProfile,
|
||||
downloadProfile,
|
||||
};
|
||||
}
|
||||
|
||||
const RUNTIME_LANE_SPECS = Object.fromEntries(
|
||||
Object.values(HWLAB_NODE_LANE_CONFIG.lanes).map((config) => [config.id, buildRuntimeLaneSpec(config)]),
|
||||
) as Record<HwlabRuntimeLane, HwlabRuntimeLaneSpec>;
|
||||
|
||||
export function isHwlabRuntimeLane(value: string): value is HwlabRuntimeLane {
|
||||
return Object.prototype.hasOwnProperty.call(RUNTIME_LANE_SPECS, value);
|
||||
}
|
||||
|
||||
export function hwlabRuntimeLaneSpec(lane: HwlabRuntimeLane): HwlabRuntimeLaneSpec {
|
||||
return RUNTIME_LANE_SPECS[lane];
|
||||
}
|
||||
|
||||
export function hwlabRuntimeLaneIds(): HwlabRuntimeLane[] {
|
||||
return Object.keys(RUNTIME_LANE_SPECS) as HwlabRuntimeLane[];
|
||||
}
|
||||
|
||||
export function hwlabRuntimeNodeIds(): string[] {
|
||||
return Object.keys(HWLAB_NODE_LANE_CONFIG.nodes);
|
||||
}
|
||||
|
||||
export function hwlabRuntimeLaneConfigPath(): string {
|
||||
return HWLAB_NODE_LANE_CONFIG_PATH;
|
||||
}
|
||||
|
||||
export function hwlabRequiredNoProxyEntries(): string[] {
|
||||
return [...HWLAB_NODE_LANE_CONFIG.requiredNoProxy];
|
||||
}
|
||||
Reference in New Issue
Block a user