Files
pikasTech-unidesk/scripts/native/deploy/render-unidesk-host-k8s.mjs
T

473 lines
17 KiB
JavaScript
Executable File

#!/usr/bin/env bun
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
function required(value, path) {
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
return value;
}
function record(value, path) {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value;
}
function readEnvFile(path) {
const values = {};
const text = readFileSync(path, "utf8");
for (const [index, rawLine] of text.split(/\r?\n/u).entries()) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) throw new Error(`${path}:${index + 1} must be KEY=value`);
values[line.slice(0, eq)] = line.slice(eq + 1);
}
return values;
}
function sha(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function yamlScalar(value) {
return JSON.stringify(String(value));
}
function indent(text, spaces) {
const pad = " ".repeat(spaces);
return text.split("\n").map((line) => line.length > 0 ? `${pad}${line}` : line).join("\n");
}
function labels(config) {
const name = required(record(config.metadata, "metadata").name, "metadata.name");
return [
"app.kubernetes.io/part-of: unidesk",
`unidesk.ai/deployment: ${name}`,
`unidesk.ai/target: ${required(record(config.target, "target").id, "target.id")}`,
].join("\n");
}
function envFromSecret(secretName, keyName, envName) {
return [
`- name: ${envName}`,
" valueFrom:",
" secretKeyRef:",
` name: ${secretName}`,
` key: ${keyName}`,
].join("\n");
}
function envFromConfig(configName, keyName, envName) {
return [
`- name: ${envName}`,
" valueFrom:",
" configMapKeyRef:",
` name: ${configName}`,
` key: ${keyName}`,
].join("\n");
}
function resourceBlock(resources) {
const requests = record(record(resources, "resources").requests, "resources.requests");
const limits = record(resources.limits, "resources.limits");
return [
"resources:",
" requests:",
` cpu: ${yamlScalar(required(requests.cpu, "resources.requests.cpu"))}`,
` memory: ${yamlScalar(required(requests.memory, "resources.requests.memory"))}`,
" limits:",
...(limits.cpu ? [` cpu: ${yamlScalar(limits.cpu)}`] : []),
` memory: ${yamlScalar(required(limits.memory, "resources.limits.memory"))}`,
].join("\n");
}
const configPath = process.argv[2] ?? "config/unidesk-host-k8s.yaml";
const config = Bun.YAML.parse(readFileSync(configPath, "utf8"));
const target = record(config.target, "target");
const runtime = record(config.runtime, "runtime");
const images = record(config.images, "images");
const database = record(config.database, "database");
const services = record(config.services, "services");
const backend = record(services.backendCore, "services.backendCore");
const frontend = record(services.frontend, "services.frontend");
const runtimeConfig = record(config.runtimeConfig, "runtimeConfig");
const runtimeSecrets = record(config.runtimeSecrets, "runtimeSecrets");
const secretTarget = record(runtimeSecrets.target, "runtimeSecrets.target");
const secretKeys = record(secretTarget.keys, "runtimeSecrets.target.keys");
const namespace = required(target.namespace, "target.namespace");
const secretName = required(secretTarget.name, "runtimeSecrets.target.name");
const sourcePath = resolve(required(record(runtimeSecrets.sourceRef, "runtimeSecrets.sourceRef").path, "runtimeSecrets.sourceRef.path"));
const secrets = readEnvFile(sourcePath);
const requiredSecretKeys = Object.values(secretKeys);
for (const key of requiredSecretKeys) {
if (typeof secrets[key] !== "string" || secrets[key].length === 0) throw new Error(`missing ${key} in ${sourcePath}`);
}
const secretFingerprint = sha(requiredSecretKeys.map((key) => `${key}=${secrets[key]}`).join("\n"));
const runtimeConfigFingerprint = sha(JSON.stringify(runtimeConfig));
const runtimeConfigName = "unidesk-runtime-config";
const databaseMode = database.mode === undefined ? "k8s-postgres" : required(database.mode, "database.mode");
if (databaseMode !== "k8s-postgres" && databaseMode !== "host-native") throw new Error("database.mode must be k8s-postgres or host-native");
const dbService = required(database.serviceName, "database.serviceName");
const dbHost = databaseMode === "host-native" ? required(database.host, "database.host") : `${dbService}.${namespace}.svc.cluster.local`;
const dbPort = required(String(database.port), "database.port");
const dbName = secrets[secretKeys.postgresDb];
if (secrets[secretKeys.databaseUrl] !== `postgres://${secrets[secretKeys.postgresUser]}:${secrets[secretKeys.postgresPassword]}@${dbHost}:${dbPort}/${dbName}`) {
throw new Error(`DATABASE_URL in ${sourcePath} must target ${dbHost}:${dbPort}/${dbName}`);
}
const docs = [];
docs.push(`apiVersion: v1
kind: Namespace
metadata:
name: ${namespace}
labels:
${indent(labels(config), 4)}`);
docs.push(`apiVersion: v1
kind: Secret
metadata:
name: ${secretName}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
annotations:
unidesk.ai/source-ref: ${yamlScalar(required(record(runtimeSecrets.sourceRef, "runtimeSecrets.sourceRef").path, "runtimeSecrets.sourceRef.path"))}
unidesk.ai/fingerprint: ${yamlScalar(secretFingerprint)}
type: Opaque
stringData:
${indent(requiredSecretKeys.map((key) => `${key}: ${yamlScalar(secrets[key])}`).join("\n"), 2)}`);
docs.push(`apiVersion: v1
kind: ConfigMap
metadata:
name: ${runtimeConfigName}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
data:
UNIDESK_ENV: ${yamlScalar(required(runtime.environment, "runtime.environment"))}
UNIDESK_NAMESPACE: ${yamlScalar(namespace)}
UNIDESK_DEPLOY_REF: ${yamlScalar(required(runtime.deployRef, "runtime.deployRef"))}
UNIDESK_DEPLOY_REPO: ${yamlScalar(required(runtime.repository, "runtime.repository"))}
UNIDESK_DEPLOY_COMMIT: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
UNIDESK_DEPLOY_REQUESTED_COMMIT: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
UNIDESK_DATABASE_NAME: ${yamlScalar(dbName)}
UNIDESK_DATABASE_MODE: ${yamlScalar(databaseMode)}
UNIDESK_DATABASE_HOST: ${yamlScalar(dbHost)}
UNIDESK_DATABASE_PORT: ${yamlScalar(dbPort)}
DATABASE_VOLUME_NAME: ${yamlScalar(required(database.pvcName, "database.pvcName"))}
DATABASE_VOLUME_SIZE: ${yamlScalar(required(database.storageSize, "database.storageSize"))}
HEARTBEAT_TIMEOUT_MS: ${yamlScalar(required(runtimeConfig.heartBeatTimeoutMs, "runtimeConfig.heartBeatTimeoutMs"))}
TASK_PENDING_TIMEOUT_MS: ${yamlScalar(required(runtimeConfig.taskPendingTimeoutMs, "runtimeConfig.taskPendingTimeoutMs"))}
DATABASE_POOL_MAX: ${yamlScalar(required(runtimeConfig.databasePoolMax, "runtimeConfig.databasePoolMax"))}
SESSION_TTL_SECONDS: ${yamlScalar(required(runtimeConfig.sessionTtlSeconds, "runtimeConfig.sessionTtlSeconds"))}
AUTH_USERNAME: ${yamlScalar(required(runtimeConfig.authUsername, "runtimeConfig.authUsername"))}
UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: ${yamlScalar(required(runtimeConfig.sshClientRouteAllowlist, "runtimeConfig.sshClientRouteAllowlist"))}
MICROSERVICES_JSON: ${yamlScalar(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson"))}
NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}
no_proxy: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}`);
if (databaseMode === "k8s-postgres") {
docs.push(`apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ${required(database.pvcName, "database.pvcName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
accessModes:
- ReadWriteOnce
storageClassName: ${required(database.storageClassName, "database.storageClassName")}
resources:
requests:
storage: ${required(database.storageSize, "database.storageSize")}`);
docs.push(`apiVersion: v1
kind: ConfigMap
metadata:
name: unidesk-db-init
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
data:
001_unidesk_init.sql: |
${indent(readFileSync("src/components/database/init/001_unidesk_init.sql", "utf8"), 4)}`);
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${dbService}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: database
ports:
- name: pg
port: ${database.port}
targetPort: pg`);
docs.push(`apiVersion: apps/v1
kind: Deployment
metadata:
name: database
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: database
template:
metadata:
labels:
app.kubernetes.io/name: database
app.kubernetes.io/part-of: unidesk
annotations:
unidesk.ai/secret-fingerprint: ${yamlScalar(secretFingerprint)}
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
spec:
securityContext:
fsGroup: 70
containers:
- name: postgres
image: ${required(images.postgres, "images.postgres")}
imagePullPolicy: IfNotPresent
ports:
- name: pg
containerPort: ${database.port}
env:
${indent(envFromSecret(secretName, secretKeys.postgresUser, "POSTGRES_USER"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresPassword, "POSTGRES_PASSWORD"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresDb, "POSTGRES_DB"), 12)}
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: init
mountPath: /docker-entrypoint-initdb.d
readOnly: true
readinessProbe:
exec:
command: ["sh", "-ec", "pg_isready -U \\"$POSTGRES_USER\\" -d \\"$POSTGRES_DB\\""]
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 18
startupProbe:
exec:
command: ["sh", "-ec", "pg_isready -U \\"$POSTGRES_USER\\" -d \\"$POSTGRES_DB\\""]
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
${indent(resourceBlock(database.resources), 10)}
volumes:
- name: data
persistentVolumeClaim:
claimName: ${required(database.pvcName, "database.pvcName")}
- name: init
configMap:
name: unidesk-db-init`);
}
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${required(backend.serviceName, "services.backendCore.serviceName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: backend-core
ports:
- name: http
port: ${backend.containerPort}
targetPort: http
- name: provider
port: ${backend.providerPort}
targetPort: provider
- name: provider-data
port: ${backend.providerDataPort}
targetPort: provider-data`);
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${required(backend.providerPublicServiceName, "services.backendCore.providerPublicServiceName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: backend-core
ports:
- name: provider
port: ${backend.providerPublicPort}
targetPort: provider
- name: provider-data
port: ${backend.providerDataPublicPort}
targetPort: provider-data`);
docs.push(`apiVersion: apps/v1
kind: Deployment
metadata:
name: ${required(backend.deploymentName, "services.backendCore.deploymentName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: backend-core
template:
metadata:
labels:
app.kubernetes.io/name: backend-core
app.kubernetes.io/part-of: unidesk
annotations:
unidesk.ai/secret-fingerprint: ${yamlScalar(secretFingerprint)}
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
spec:
initContainers:
- name: wait-database
image: ${required(images.postgres, "images.postgres")}
imagePullPolicy: IfNotPresent
env:
- name: PGHOST
value: ${yamlScalar(dbHost)}
- name: PGPORT
value: ${yamlScalar(dbPort)}
${indent(envFromSecret(secretName, secretKeys.postgresUser, "PGUSER"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresPassword, "PGPASSWORD"), 12)}
${indent(envFromSecret(secretName, secretKeys.postgresDb, "PGDATABASE"), 12)}
command: ["sh", "-ec", "until pg_isready -h \\"$PGHOST\\" -p \\"$PGPORT\\" -U \\"$PGUSER\\" -d \\"$PGDATABASE\\" >/dev/null 2>&1; do sleep 2; done"]
containers:
- name: backend-core
image: ${required(images.backendCore, "images.backendCore")}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: ${backend.containerPort}
- name: provider
containerPort: ${backend.providerPort}
- name: provider-data
containerPort: ${backend.providerDataPort}
envFrom:
- configMapRef:
name: ${runtimeConfigName}
env:
- name: PORT
value: ${yamlScalar(backend.containerPort)}
- name: PROVIDER_PORT
value: ${yamlScalar(backend.providerPort)}
- name: PROVIDER_DATA_PORT
value: ${yamlScalar(backend.providerDataPort)}
${indent(envFromSecret(secretName, secretKeys.databaseUrl, "DATABASE_URL"), 12)}
${indent(envFromSecret(secretName, secretKeys.providerToken, "PROVIDER_TOKEN"), 12)}
- name: LOG_FILE
value: ${yamlScalar(required(backend.logFile, "services.backendCore.logFile"))}
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 20
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
${indent(resourceBlock(backend.resources), 10)}
volumes:
- name: logs
emptyDir: {}`);
docs.push(`apiVersion: v1
kind: Service
metadata:
name: ${required(frontend.serviceName, "services.frontend.serviceName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: frontend
ports:
- name: http
port: ${frontend.publicPort}
targetPort: http`);
docs.push(`apiVersion: apps/v1
kind: Deployment
metadata:
name: ${required(frontend.deploymentName, "services.frontend.deploymentName")}
namespace: ${namespace}
labels:
${indent(labels(config), 4)}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: frontend
template:
metadata:
labels:
app.kubernetes.io/name: frontend
app.kubernetes.io/part-of: unidesk
annotations:
unidesk.ai/secret-fingerprint: ${yamlScalar(secretFingerprint)}
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
spec:
containers:
- name: frontend
image: ${required(images.frontend, "images.frontend")}
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: ${frontend.containerPort}
envFrom:
- configMapRef:
name: ${runtimeConfigName}
env:
- name: PORT
value: ${yamlScalar(frontend.containerPort)}
- name: CORE_INTERNAL_URL
value: ${yamlScalar(`http://${backend.serviceName}.${namespace}.svc.cluster.local:${backend.containerPort}`)}
- name: FRONTEND_PUBLIC_URL
value: ${yamlScalar(`http://${runtime.publicHost}:${frontend.publicPort}`)}
- name: PROVIDER_INGRESS_PUBLIC_URL
value: ${yamlScalar(`ws://${runtime.publicHost}:${backend.providerPort}/ws/provider`)}
${indent(envFromConfig(runtimeConfigName, "AUTH_USERNAME", "AUTH_USERNAME"), 12)}
${indent(envFromSecret(secretName, secretKeys.authPassword, "AUTH_PASSWORD"), 12)}
${indent(envFromSecret(secretName, secretKeys.providerToken, "PROVIDER_TOKEN"), 12)}
${indent(envFromSecret(secretName, secretKeys.sshClientToken, "UNIDESK_SSH_CLIENT_TOKEN"), 12)}
${indent(envFromSecret(secretName, secretKeys.sessionSecret, "SESSION_SECRET"), 12)}
- name: LOG_FILE
value: ${yamlScalar(required(frontend.logFile, "services.frontend.logFile"))}
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 20
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
${indent(resourceBlock(frontend.resources), 10)}
volumes:
- name: logs
emptyDir: {}`);
process.stdout.write(`${docs.join("\n---\n")}\n`);