Files
pikasTech-HWLAB/scripts/src/runtime-lane.ts
T
2026-07-15 16:47:05 +02:00

278 lines
13 KiB
TypeScript

type RuntimeLaneDefaults = {
defaultNodeId?: string;
defaultGitopsRoot?: string;
defaultBranch?: string;
defaultGitopsBranch?: string;
defaultCatalogPath?: string;
defaultRuntimeEndpoint?: string;
defaultWebEndpoint?: string;
defaultV02RuntimeEndpoint?: string;
defaultSourceRepo?: string;
};
export type GitOpsRenderArgs = {
lane: string;
nodeId?: string;
outDir: string;
gitopsRoot?: string;
sourceBranch: string;
gitopsBranch: string;
catalogPath: string;
imageTagMode: string;
runtimeEndpoint: string;
webEndpoint: string;
sourceRepo: string;
[key: string]: unknown;
};
export type RuntimeLaneConfig = {
name?: string;
node?: string;
sourceBranch?: string;
gitopsBranch?: string;
gitReadUrl?: string;
gitWriteUrl?: string;
artifactCatalog?: string;
runtimePath?: string;
namespace?: string;
imageTagMode?: string;
endpoint?: string;
externalPostgres?: {
enabled?: boolean;
serviceName?: string;
endpointAddress?: string;
port?: number;
};
publicEndpoints?: {
frontend?: string;
api?: string;
[key: string]: string | undefined;
};
observability?: {
traceExplorerUrlTemplate?: string;
prometheusOperatorResources?: boolean;
};
sourceRepo?: string;
};
export type NodeConfig = {
gitopsRoot?: string;
sourceRepo?: string;
publicHost?: string;
};
export type RuntimeLaneMirrorSpec = {
lane: string;
sourceBranch: string;
gitopsBranch: string;
artifactCatalog: string;
runtimePath: string;
};
export type DeployConfig = {
nodes?: Record<string, NodeConfig | undefined>;
lanes?: Record<string, RuntimeLaneConfig | undefined>;
};
const defaultNodeId = "node";
const defaultGitopsRoot = "deploy/gitops/node";
const defaultPublicHost = "74.48.78.17";
export function isRuntimeLane(lane: unknown): lane is string {
const value = String(lane ?? "");
return !["node", "dev", "prod"].includes(value) && /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(value);
}
export function isVersionRuntimeLane(lane: unknown): lane is string {
return /^v[0-9]{2,}$/u.test(String(lane ?? ""));
}
export function isNamedRuntimeLane(lane: unknown): lane is string {
return isRuntimeLane(lane) && !isVersionRuntimeLane(lane);
}
export function runtimeLaneMinor(lane: string): number {
invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`);
return Number.parseInt(lane.slice(1), 10);
}
export function versionNameForRuntimeLane(lane: string): string {
return `v0.${runtimeLaneMinor(lane)}`;
}
export function namespaceNameForRuntimeLane(lane: string): string {
invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`);
return `hwlab-${lane}`;
}
export function runtimePathForRuntimeLane(lane: string): string {
invariant(isVersionRuntimeLane(lane), `version runtime lane must look like v02/v03, got ${lane}`);
return `runtime-${lane}`;
}
export function nodeConfig(deploy: DeployConfig, nodeId: string): NodeConfig {
const config = deploy.nodes?.[nodeId];
return isObject(config) ? config : {};
}
export function nodeIdForRuntimeLane(deploy: DeployConfig, lane: string, defaults: RuntimeLaneDefaults = {}): string {
const laneConfig = runtimeLaneConfig(deploy, lane);
return laneConfig.node ?? defaults.defaultNodeId ?? defaultNodeId;
}
export function gitopsRootForNode(deploy: DeployConfig, nodeId: string, defaults: RuntimeLaneDefaults = {}): string {
const config = nodeConfig(deploy, nodeId);
return normalizeRepoPath(config.gitopsRoot ?? defaults.defaultGitopsRoot ?? defaultGitopsRoot);
}
export function runtimeGitopsPathForRuntimeLane(deploy: DeployConfig, lane: string, defaults: RuntimeLaneDefaults = {}): string {
const laneConfig = runtimeLaneConfig(deploy, lane);
const runtimePath = normalizeRepoPath(laneConfig.runtimePath ?? runtimePathForRuntimeLane(lane));
if (runtimePath.startsWith("deploy/")) return runtimePath;
return joinRepoPath(gitopsRootForNode(deploy, nodeIdForRuntimeLane(deploy, lane, defaults), defaults), runtimePath);
}
export function defaultPortForRuntimeLane(lane: string): number {
return 17666 + runtimeLaneMinor(lane) * 1000;
}
export function publicHostLabel(host = defaultPublicHost): string {
return host.replaceAll(".", "-");
}
export function defaultEndpointForRuntimeLane(lane: string, options: { host?: string; v02Endpoint?: string } = {}): string {
if (lane === "v02" && options.v02Endpoint) return options.v02Endpoint;
return `https://hwlab-${lane}.${publicHostLabel(options.host ?? defaultPublicHost)}.nip.io`;
}
export function runtimeLaneConfig(deploy: DeployConfig, lane: string): RuntimeLaneConfig {
invariant(isRuntimeLane(lane), `runtime lane must be a stable lowercase id, got ${lane}`);
const config = deploy.lanes?.[lane];
invariant(isObject(config), `deploy.lanes.${lane} is required`);
if (isNamedRuntimeLane(lane)) validateNamedRuntimeLaneConfig(config, lane);
return config;
}
export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): GitOpsRenderArgs {
if (!isRuntimeLane(args.lane)) return args;
const result: GitOpsRenderArgs = { ...args };
const laneConfig = runtimeLaneConfig(deploy, args.lane);
const configuredNodeId = nodeIdForRuntimeLane(deploy, args.lane, defaults);
const rootNodeId = nodeIdFromGitopsRoot(result.gitopsRoot);
const requestedNodeId = result.nodeId && result.nodeId !== defaultNodeId ? result.nodeId : rootNodeId ?? configuredNodeId;
const nodeId = isNamedRuntimeLane(args.lane) ? configuredNodeId : requestedNodeId;
const node = nodeConfig(deploy, nodeId);
const versionName = isVersionRuntimeLane(args.lane) ? versionNameForRuntimeLane(args.lane) : null;
const defaultEndpointOptions: { host?: string; v02Endpoint?: string } = {};
if (node.publicHost) defaultEndpointOptions.host = node.publicHost;
if (defaults.defaultV02RuntimeEndpoint) defaultEndpointOptions.v02Endpoint = defaults.defaultV02RuntimeEndpoint;
const defaultEndpoint = versionName ? defaultEndpointForRuntimeLane(args.lane, defaultEndpointOptions) : null;
const configuredEndpoint = laneConfig.endpoint ?? defaultEndpoint;
const configuredWebEndpoint = laneConfig.publicEndpoints?.frontend ?? configuredEndpoint;
const configuredRuntimeEndpoint = laneConfig.publicEndpoints?.api ?? configuredEndpoint;
const defaultBranch = defaults.defaultBranch ?? "node";
const defaultGitopsBranch = defaults.defaultGitopsBranch ?? "node-gitops";
const defaultCatalogPath = defaults.defaultCatalogPath ?? "deploy/artifact-catalog.dev.json";
const defaultRuntimeEndpoint = defaults.defaultRuntimeEndpoint ?? "http://74.48.78.17:17667";
const defaultWebEndpoint = defaults.defaultWebEndpoint ?? "http://74.48.78.17:17666";
const defaultSourceRepo = defaults.defaultSourceRepo ?? "git@github.com:pikasTech/HWLAB.git";
const defaultCatalog = `deploy/artifact-catalog.${args.lane}.json`;
if (isNamedRuntimeLane(args.lane)) {
const configuredSourceRepo = laneConfig.sourceRepo ?? node.sourceRepo;
invariant(typeof node.gitopsRoot === "string" && node.gitopsRoot.trim().length > 0, `deploy.nodes.${nodeId}.gitopsRoot is required for named runtime lane ${args.lane}`);
requiredString(configuredSourceRepo, `deploy.lanes.${args.lane}.sourceRepo or deploy.nodes.${nodeId}.sourceRepo`);
result.nodeId = configuredNodeId;
result.gitopsRoot = normalizeRepoPath(node.gitopsRoot);
result.sourceBranch = requiredString(laneConfig.sourceBranch, `deploy.lanes.${args.lane}.sourceBranch`);
result.gitopsBranch = requiredString(laneConfig.gitopsBranch, `deploy.lanes.${args.lane}.gitopsBranch`);
result.catalogPath = normalizeRepoPath(requiredString(laneConfig.artifactCatalog, `deploy.lanes.${args.lane}.artifactCatalog`));
result.imageTagMode = requiredString(laneConfig.imageTagMode, `deploy.lanes.${args.lane}.imageTagMode`);
result.runtimeEndpoint = requiredString(laneConfig.publicEndpoints?.api, `deploy.lanes.${args.lane}.publicEndpoints.api`);
result.webEndpoint = requiredString(laneConfig.publicEndpoints?.frontend, `deploy.lanes.${args.lane}.publicEndpoints.frontend`);
result.sourceRepo = configuredSourceRepo as string;
result.gitReadUrl = requiredString(laneConfig.gitReadUrl, `deploy.lanes.${args.lane}.gitReadUrl`);
result.gitWriteUrl = requiredString(laneConfig.gitWriteUrl, `deploy.lanes.${args.lane}.gitWriteUrl`);
return result;
}
if (!result.nodeId || result.nodeId === defaultNodeId) result.nodeId = nodeId;
if (!result.gitopsRoot || result.gitopsRoot === (defaults.defaultGitopsRoot ?? defaultGitopsRoot)) result.gitopsRoot = gitopsRootForNode(deploy, nodeId, defaults);
if (result.sourceBranch === defaultBranch || result.sourceBranch === versionName) result.sourceBranch = laneConfig.sourceBranch ?? versionName ?? result.sourceBranch;
if (result.gitopsBranch === defaultGitopsBranch || result.gitopsBranch === `${versionName}-gitops`) result.gitopsBranch = laneConfig.gitopsBranch ?? (versionName ? `${versionName}-gitops` : result.gitopsBranch);
if (result.catalogPath === defaultCatalogPath || result.catalogPath === defaultCatalog) result.catalogPath = laneConfig.artifactCatalog ?? defaultCatalog;
if (result.imageTagMode === "full") result.imageTagMode = laneConfig.imageTagMode ?? result.imageTagMode;
if (result.runtimeEndpoint === defaultRuntimeEndpoint || result.runtimeEndpoint === defaultEndpoint) result.runtimeEndpoint = configuredRuntimeEndpoint ?? result.runtimeEndpoint;
if (result.webEndpoint === defaultWebEndpoint || result.webEndpoint === defaultEndpoint) result.webEndpoint = configuredWebEndpoint ?? result.webEndpoint;
if (result.sourceRepo === defaultSourceRepo) result.sourceRepo = laneConfig.sourceRepo ?? node.sourceRepo ?? result.sourceRepo;
return result;
}
function nodeIdFromGitopsRoot(gitopsRoot: unknown): string | undefined {
if (typeof gitopsRoot !== "string") return undefined;
const normalized = normalizeRepoPath(gitopsRoot);
const match = normalized.match(/(?:^|\/)deploy\/gitops\/node\/([^/]+)$/u);
return match?.[1] ? match[1].toUpperCase() : undefined;
}
export function runtimeLaneMirrorSpecs(deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): RuntimeLaneMirrorSpec[] {
return Object.entries(deploy.lanes ?? {})
.filter(([lane, config]) => isRuntimeLane(lane) && isObject(config))
.sort(([left], [right]) => left.localeCompare(right, "en"))
.map(([lane, config]) => {
const laneConfig = config as RuntimeLaneConfig;
const versionName = isVersionRuntimeLane(lane) ? versionNameForRuntimeLane(lane) : null;
return {
lane,
sourceBranch: laneConfig.sourceBranch ?? versionName ?? requiredString(laneConfig.sourceBranch, `deploy.lanes.${lane}.sourceBranch`),
gitopsBranch: laneConfig.gitopsBranch ?? (versionName ? `${versionName}-gitops` : requiredString(laneConfig.gitopsBranch, `deploy.lanes.${lane}.gitopsBranch`)),
artifactCatalog: laneConfig.artifactCatalog ?? `deploy/artifact-catalog.${lane}.json`,
runtimePath: runtimeGitopsPathForRuntimeLane(deploy, lane, defaults),
};
});
}
export function runtimeNamespaceForRuntimeLane(deploy: DeployConfig, lane: string): string {
const laneConfig = runtimeLaneConfig(deploy, lane);
return laneConfig.namespace ?? namespaceNameForRuntimeLane(lane);
}
export function runtimeRelativePathForRuntimeLane(deploy: DeployConfig, lane: string): string {
const laneConfig = runtimeLaneConfig(deploy, lane);
return normalizeRepoPath(laneConfig.runtimePath ?? runtimePathForRuntimeLane(lane));
}
function validateNamedRuntimeLaneConfig(config: RuntimeLaneConfig, lane: string): void {
const label = `deploy.lanes.${lane}`;
for (const key of ["name", "node", "sourceBranch", "gitopsBranch", "gitReadUrl", "gitWriteUrl", "artifactCatalog", "runtimePath", "namespace", "imageTagMode"] as const) {
requiredString(config[key], `${label}.${key}`);
}
invariant(config.imageTagMode === "short" || config.imageTagMode === "full", `${label}.imageTagMode must be short or full`);
invariant(isObject(config.publicEndpoints), `${label}.publicEndpoints is required`);
requiredString(config.publicEndpoints.frontend, `${label}.publicEndpoints.frontend`);
requiredString(config.publicEndpoints.api, `${label}.publicEndpoints.api`);
invariant(Array.isArray((config as Record<string, unknown>).envReuseServices) && ((config as Record<string, unknown>).envReuseServices as unknown[]).length > 0, `${label}.envReuseServices must not be empty`);
invariant(isObject((config as Record<string, unknown>).serviceDeclarations), `${label}.serviceDeclarations is required`);
}
function requiredString(value: unknown, label: string): string {
invariant(typeof value === "string" && value.trim().length > 0, `${label} must be a non-empty string`);
return value.trim();
}
function normalizeRepoPath(value: string): string {
return value.replaceAll("\\\\", "/").replace(/^\.\//u, "").replace(/\/+$/u, "");
}
function joinRepoPath(left: string, right: string): string {
return `${normalizeRepoPath(left)}/${normalizeRepoPath(right).replace(/^\/+/, "")}`;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function invariant(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}