333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export interface UniDeskConfig {
|
|
project: { name: string; timezone: string };
|
|
runtime: { typescript: "bun"; bunVersion: string };
|
|
network: {
|
|
host: string;
|
|
publicHost: string;
|
|
core: { port: number; containerPort: number };
|
|
frontend: { port: number; containerPort: number };
|
|
devFrontend: { port: number; containerPort: number };
|
|
database: { port: number; containerPort: number };
|
|
providerIngress: { port: number; containerPort: number };
|
|
providerData: { port: number; containerPort: number };
|
|
restrictedHostAccess?: { bindHost: string; allowedSourceCidrs: string[] };
|
|
};
|
|
auth: { username: string; password: string; sessionSecret: string; sessionTtlSeconds: number };
|
|
database: { user: string; password: string; name: string; volume: string; volumeSize: string };
|
|
providerGateway: {
|
|
id: string;
|
|
name: string;
|
|
token: string;
|
|
labels: Record<string, unknown>;
|
|
heartbeatIntervalMs: number;
|
|
reconnectBaseMs: number;
|
|
reconnectMaxMs: number;
|
|
metrics: { diskPath: string };
|
|
upgrade: {
|
|
hostProjectRoot: string;
|
|
workspacePath: string;
|
|
composeFile: string;
|
|
composeEnvFile: string;
|
|
composeProject: string;
|
|
service: string;
|
|
runnerImage: string;
|
|
};
|
|
};
|
|
docker: { composeFile: string; projectName: string };
|
|
microservices: UniDeskMicroserviceConfig[];
|
|
paths: { stateDir: string; logsDir: string; docsReferenceDir: string };
|
|
sshForwarding: { mode: string; keyDir: string; host: string; port: number; user: string };
|
|
}
|
|
|
|
export interface UniDeskMicroserviceConfig {
|
|
id: string;
|
|
name: string;
|
|
providerId: string;
|
|
description: string;
|
|
repository: {
|
|
url: string;
|
|
commitId: string;
|
|
dockerfile: string;
|
|
artifactSource?: {
|
|
kind: "upstream-image";
|
|
imageRef: string;
|
|
digestPinRequired: boolean;
|
|
mirrorRepository: string;
|
|
ciDockerfileBuild: boolean;
|
|
pullOnlyCd: boolean;
|
|
};
|
|
composeFile: string;
|
|
composeService: string;
|
|
containerName: string;
|
|
};
|
|
backend: {
|
|
nodeBaseUrl: string;
|
|
nodeBindHost: string;
|
|
nodePort: number;
|
|
proxyMode: string;
|
|
frontendOnly: boolean;
|
|
public: boolean;
|
|
allowedMethods: string[];
|
|
allowedPathPrefixes: string[];
|
|
healthPath: string;
|
|
timeoutMs: number;
|
|
};
|
|
deployment: {
|
|
mode: "unidesk-direct" | "k3sctl-managed" | "internal-sidecar";
|
|
adapterServiceId?: string;
|
|
k3sServiceId?: string;
|
|
namespace?: string;
|
|
expectedNodeIds?: string[];
|
|
activeNodeId?: string;
|
|
};
|
|
development: {
|
|
providerId: string;
|
|
sshPassthrough: boolean;
|
|
worktreePath: string;
|
|
};
|
|
frontend: {
|
|
route: string;
|
|
integrated: boolean;
|
|
};
|
|
}
|
|
|
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
export const repoRoot = join(moduleDir, "..", "..");
|
|
|
|
export function rootPath(...parts: string[]): string {
|
|
return join(repoRoot, ...parts);
|
|
}
|
|
|
|
function asRecord(value: unknown, name: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error(`${name} 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.isFinite(value) || value <= 0) throw new Error(`${path}.${key} must be a positive number`);
|
|
return value;
|
|
}
|
|
|
|
function portPair(obj: Record<string, unknown>, key: string): { port: number; containerPort: number } {
|
|
const value = asRecord(obj[key], `network.${key}`);
|
|
return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) };
|
|
}
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
const value = obj[key];
|
|
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
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 optionalArray(value: unknown, name: string): Record<string, unknown>[] {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
|
return value.map((item, index) => asRecord(item, `${name}[${index}]`));
|
|
}
|
|
|
|
function optionalStringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] | undefined {
|
|
const value = obj[key];
|
|
if (value === undefined) return undefined;
|
|
return stringArrayField(obj, key, path);
|
|
}
|
|
|
|
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 optionalArtifactSource(repository: Record<string, unknown>, path: string): UniDeskMicroserviceConfig["repository"]["artifactSource"] {
|
|
const value = repository.artifactSource;
|
|
if (value === undefined) return undefined;
|
|
const source = asRecord(value, `${path}.artifactSource`);
|
|
const kind = stringField(source, "kind", `${path}.artifactSource`);
|
|
if (kind !== "upstream-image") throw new Error(`${path}.artifactSource.kind must be upstream-image`);
|
|
return {
|
|
kind,
|
|
imageRef: stringField(source, "imageRef", `${path}.artifactSource`),
|
|
digestPinRequired: booleanField(source, "digestPinRequired", `${path}.artifactSource`),
|
|
mirrorRepository: stringField(source, "mirrorRepository", `${path}.artifactSource`),
|
|
ciDockerfileBuild: booleanField(source, "ciDockerfileBuild", `${path}.artifactSource`),
|
|
pullOnlyCd: booleanField(source, "pullOnlyCd", `${path}.artifactSource`),
|
|
};
|
|
}
|
|
|
|
function optionalRestrictedHostAccess(network: Record<string, unknown>): UniDeskConfig["network"]["restrictedHostAccess"] {
|
|
const value = network.restrictedHostAccess;
|
|
if (value === undefined) return undefined;
|
|
const record = asRecord(value, "network.restrictedHostAccess");
|
|
const allowedSourceCidrs = stringArrayField(record, "allowedSourceCidrs", "network.restrictedHostAccess");
|
|
if (allowedSourceCidrs.length === 0) throw new Error("network.restrictedHostAccess.allowedSourceCidrs must not be empty");
|
|
return {
|
|
bindHost: stringField(record, "bindHost", "network.restrictedHostAccess"),
|
|
allowedSourceCidrs,
|
|
};
|
|
}
|
|
|
|
function microserviceConfig(item: Record<string, unknown>, index: number): UniDeskMicroserviceConfig {
|
|
const path = `microservices[${index}]`;
|
|
const repository = asRecord(item.repository, `${path}.repository`);
|
|
const backend = asRecord(item.backend, `${path}.backend`);
|
|
const development = asRecord(item.development, `${path}.development`);
|
|
const frontend = asRecord(item.frontend, `${path}.frontend`);
|
|
const deployment = item.deployment === undefined ? undefined : asRecord(item.deployment, `${path}.deployment`);
|
|
const deploymentMode = deployment === undefined ? "unidesk-direct" : stringField(deployment, "mode", `${path}.deployment`);
|
|
if (deploymentMode !== "unidesk-direct" && deploymentMode !== "k3sctl-managed" && deploymentMode !== "internal-sidecar") {
|
|
throw new Error(`${path}.deployment.mode must be unidesk-direct, k3sctl-managed, or internal-sidecar`);
|
|
}
|
|
return {
|
|
id: stringField(item, "id", path),
|
|
name: stringField(item, "name", path),
|
|
providerId: stringField(item, "providerId", path),
|
|
description: stringField(item, "description", path),
|
|
repository: {
|
|
url: stringField(repository, "url", `${path}.repository`),
|
|
commitId: stringField(repository, "commitId", `${path}.repository`),
|
|
dockerfile: stringField(repository, "dockerfile", `${path}.repository`),
|
|
artifactSource: optionalArtifactSource(repository, `${path}.repository`),
|
|
composeFile: stringField(repository, "composeFile", `${path}.repository`),
|
|
composeService: stringField(repository, "composeService", `${path}.repository`),
|
|
containerName: stringField(repository, "containerName", `${path}.repository`),
|
|
},
|
|
backend: {
|
|
nodeBaseUrl: stringField(backend, "nodeBaseUrl", `${path}.backend`),
|
|
nodeBindHost: stringField(backend, "nodeBindHost", `${path}.backend`),
|
|
nodePort: numberField(backend, "nodePort", `${path}.backend`),
|
|
proxyMode: stringField(backend, "proxyMode", `${path}.backend`),
|
|
frontendOnly: booleanField(backend, "frontendOnly", `${path}.backend`),
|
|
public: booleanField(backend, "public", `${path}.backend`),
|
|
allowedMethods: stringArrayField(backend, "allowedMethods", `${path}.backend`),
|
|
allowedPathPrefixes: stringArrayField(backend, "allowedPathPrefixes", `${path}.backend`),
|
|
healthPath: stringField(backend, "healthPath", `${path}.backend`),
|
|
timeoutMs: numberField(backend, "timeoutMs", `${path}.backend`),
|
|
},
|
|
deployment: deployment === undefined
|
|
? { mode: "unidesk-direct" }
|
|
: {
|
|
mode: deploymentMode,
|
|
adapterServiceId: optionalStringField(deployment, "adapterServiceId", `${path}.deployment`),
|
|
k3sServiceId: optionalStringField(deployment, "k3sServiceId", `${path}.deployment`),
|
|
namespace: optionalStringField(deployment, "namespace", `${path}.deployment`),
|
|
expectedNodeIds: optionalStringArrayField(deployment, "expectedNodeIds", `${path}.deployment`),
|
|
activeNodeId: optionalStringField(deployment, "activeNodeId", `${path}.deployment`),
|
|
},
|
|
development: {
|
|
providerId: stringField(development, "providerId", `${path}.development`),
|
|
sshPassthrough: booleanField(development, "sshPassthrough", `${path}.development`),
|
|
worktreePath: stringField(development, "worktreePath", `${path}.development`),
|
|
},
|
|
frontend: {
|
|
route: stringField(frontend, "route", `${path}.frontend`),
|
|
integrated: booleanField(frontend, "integrated", `${path}.frontend`),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function readConfig(): UniDeskConfig {
|
|
const configPath = rootPath("config.json");
|
|
if (!existsSync(configPath)) throw new Error(`config.json not found at ${configPath}`);
|
|
const raw = readFileSync(configPath, "utf8");
|
|
const parsed = asRecord(JSON.parse(raw) as unknown, "config.json");
|
|
const project = asRecord(parsed.project, "project");
|
|
const runtime = asRecord(parsed.runtime, "runtime");
|
|
const network = asRecord(parsed.network, "network");
|
|
const database = asRecord(parsed.database, "database");
|
|
const auth = asRecord(parsed.auth, "auth");
|
|
const providerGateway = asRecord(parsed.providerGateway, "providerGateway");
|
|
const docker = asRecord(parsed.docker, "docker");
|
|
const microservices = optionalArray(parsed.microservices, "microservices").map(microserviceConfig);
|
|
const paths = asRecord(parsed.paths, "paths");
|
|
const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding");
|
|
const labels = asRecord(providerGateway.labels, "providerGateway.labels");
|
|
const providerMetrics = asRecord(providerGateway.metrics, "providerGateway.metrics");
|
|
const providerUpgrade = asRecord(providerGateway.upgrade, "providerGateway.upgrade");
|
|
const typescript = stringField(runtime, "typescript", "runtime");
|
|
if (typescript !== "bun") throw new Error("runtime.typescript must be bun");
|
|
return {
|
|
project: { name: stringField(project, "name", "project"), timezone: stringField(project, "timezone", "project") },
|
|
runtime: { typescript, bunVersion: stringField(runtime, "bunVersion", "runtime") },
|
|
network: {
|
|
host: stringField(network, "host", "network"),
|
|
publicHost: stringField(network, "publicHost", "network"),
|
|
core: portPair(network, "core"),
|
|
frontend: portPair(network, "frontend"),
|
|
devFrontend: portPair(network, "devFrontend"),
|
|
database: portPair(network, "database"),
|
|
providerIngress: portPair(network, "providerIngress"),
|
|
providerData: portPair(network, "providerData"),
|
|
restrictedHostAccess: optionalRestrictedHostAccess(network),
|
|
},
|
|
database: {
|
|
user: stringField(database, "user", "database"),
|
|
password: stringField(database, "password", "database"),
|
|
name: stringField(database, "name", "database"),
|
|
volume: stringField(database, "volume", "database"),
|
|
volumeSize: stringField(database, "volumeSize", "database"),
|
|
},
|
|
providerGateway: {
|
|
id: stringField(providerGateway, "id", "providerGateway"),
|
|
name: stringField(providerGateway, "name", "providerGateway"),
|
|
token: stringField(providerGateway, "token", "providerGateway"),
|
|
labels,
|
|
heartbeatIntervalMs: numberField(providerGateway, "heartbeatIntervalMs", "providerGateway"),
|
|
reconnectBaseMs: numberField(providerGateway, "reconnectBaseMs", "providerGateway"),
|
|
reconnectMaxMs: numberField(providerGateway, "reconnectMaxMs", "providerGateway"),
|
|
metrics: {
|
|
diskPath: stringField(providerMetrics, "diskPath", "providerGateway.metrics"),
|
|
},
|
|
upgrade: {
|
|
hostProjectRoot: stringField(providerUpgrade, "hostProjectRoot", "providerGateway.upgrade"),
|
|
workspacePath: stringField(providerUpgrade, "workspacePath", "providerGateway.upgrade"),
|
|
composeFile: stringField(providerUpgrade, "composeFile", "providerGateway.upgrade"),
|
|
composeEnvFile: stringField(providerUpgrade, "composeEnvFile", "providerGateway.upgrade"),
|
|
composeProject: stringField(providerUpgrade, "composeProject", "providerGateway.upgrade"),
|
|
service: stringField(providerUpgrade, "service", "providerGateway.upgrade"),
|
|
runnerImage: stringField(providerUpgrade, "runnerImage", "providerGateway.upgrade"),
|
|
},
|
|
},
|
|
docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") },
|
|
microservices,
|
|
paths: {
|
|
stateDir: stringField(paths, "stateDir", "paths"),
|
|
logsDir: stringField(paths, "logsDir", "paths"),
|
|
docsReferenceDir: stringField(paths, "docsReferenceDir", "paths"),
|
|
},
|
|
sshForwarding: {
|
|
mode: stringField(sshForwarding, "mode", "sshForwarding"),
|
|
keyDir: stringField(sshForwarding, "keyDir", "sshForwarding"),
|
|
host: stringField(sshForwarding, "host", "sshForwarding"),
|
|
port: numberField(sshForwarding, "port", "sshForwarding"),
|
|
user: stringField(sshForwarding, "user", "sshForwarding"),
|
|
},
|
|
auth: {
|
|
username: stringField(auth, "username", "auth"),
|
|
password: stringField(auth, "password", "auth"),
|
|
sessionSecret: stringField(auth, "sessionSecret", "auth"),
|
|
sessionTtlSeconds: numberField(auth, "sessionTtlSeconds", "auth"),
|
|
},
|
|
};
|
|
}
|