#!/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 positiveInteger(value, path) { if (!Number.isInteger(value) || value < 1) throw new Error(`${path} must be a positive integer`); 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 readLinePairCredential(path, usernameLine, passwordLine) { const lines = readFileSync(path, "utf8").split(/\r?\n/u); const username = (lines[usernameLine - 1] || "").trim(); const password = (lines[passwordLine - 1] || "").trim(); if (!username) throw new Error(`missing username line ${usernameLine} in ${path}`); if (!password) throw new Error(`missing password line ${passwordLine} in ${path}`); return { username, password }; } 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 optionalRecord(value, path) { if (value === undefined || value === null) return null; return record(value, path); } function optionalString(value, fallback) { return typeof value === "string" && value.length > 0 ? value : fallback; } function removeUrlSearchParam(value, paramName) { if (typeof value !== "string" || value.length === 0) return value; try { const url = new URL(value); url.searchParams.delete(paramName); return url.toString(); } catch { return value .replace(new RegExp(`([?&])${paramName}=true(&|$)`, "g"), (_match, prefix, suffix) => (prefix === "?" && suffix ? "?" : "")) .replace(/[?&]$/, ""); } } 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 delivery = config.delivery === undefined ? null : record(config.delivery, "delivery"); 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 decisionCenter = optionalRecord(services.decisionCenter, "services.decisionCenter"); const todoNote = optionalRecord(services.todoNote, "services.todoNote"); const runtimeConfig = record(config.runtimeConfig, "runtimeConfig"); const runtimeAuth = record(config.runtimeAuth, "runtimeAuth"); const runtimeAuthSourceRef = record(runtimeAuth.sourceRef, "runtimeAuth.sourceRef"); 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 authSourcePath = resolve(required(runtimeAuthSourceRef.path, "runtimeAuth.sourceRef.path")); if (required(runtimeAuthSourceRef.format, "runtimeAuth.sourceRef.format") !== "linePair") throw new Error("runtimeAuth.sourceRef.format must be linePair"); const authCredential = readLinePairCredential( authSourcePath, positiveInteger(runtimeAuthSourceRef.usernameLine, "runtimeAuth.sourceRef.usernameLine"), positiveInteger(runtimeAuthSourceRef.passwordLine, "runtimeAuth.sourceRef.passwordLine"), ); const sourcePath = resolve(required(record(runtimeSecrets.sourceRef, "runtimeSecrets.sourceRef").path, "runtimeSecrets.sourceRef.path")); const secrets = readEnvFile(sourcePath); secrets[secretKeys.authPassword] = authCredential.password; 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 runtimeConfigName = "unidesk-runtime-config"; const deliveryServiceRef = delivery === null ? null : required(delivery.serviceRef, "delivery.serviceRef"); if (delivery !== null && delivery.enabled !== true && delivery.enabled !== false) { throw new Error("delivery.enabled must be boolean"); } 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}`); } function readServiceDatabase(service, servicePath) { if (service === null) return null; const database = record(service.database, `${servicePath}.database`); const sourceRef = record(database.sourceRef, `${servicePath}.database.sourceRef`); const sourcePath = resolve(required(sourceRef.path, `${servicePath}.database.sourceRef.path`)); const sourceKey = required(sourceRef.key, `${servicePath}.database.sourceRef.key`); const sourceValues = readEnvFile(sourcePath); const value = sourceValues[sourceKey]; if (typeof value !== "string" || value.length === 0) throw new Error(`missing ${sourceKey} in ${sourcePath}`); return { sourcePath, sourceRefPath: required(sourceRef.path, `${servicePath}.database.sourceRef.path`), sourceKey, secretName: required(database.secretName, `${servicePath}.database.secretName`), secretKey: required(database.secretKey, `${servicePath}.database.secretKey`), value: removeUrlSearchParam(value, "uselibpqcompat"), fingerprint: sha(`${sourceKey}=${removeUrlSearchParam(value, "uselibpqcompat")}`), }; } function readFileSource(sourceRef, path) { const sourcePath = resolve(required(sourceRef.path, `${path}.path`)); const value = readFileSync(sourcePath, "utf8"); if (value.length === 0) throw new Error(`missing file content in ${sourcePath}`); return { sourcePath, sourceRefPath: required(sourceRef.path, `${path}.path`), value, fingerprint: sha(value), }; } function readServiceStorage(service, servicePath) { if (service === null) return null; const storage = optionalRecord(service.storage, `${servicePath}.storage`); if (storage === null) return null; const primary = required(storage.primary, `${servicePath}.storage.primary`); if (primary !== "postgres" && primary !== "github-repo") throw new Error(`${servicePath}.storage.primary must be postgres or github-repo`); const github = primary === "github-repo" ? record(storage.github, `${servicePath}.storage.github`) : null; if (github === null) return { primary }; const ssh = record(github.ssh, `${servicePath}.storage.github.ssh`); const privateKey = record(ssh.privateKey, `${servicePath}.storage.github.ssh.privateKey`); const knownHosts = record(ssh.knownHosts, `${servicePath}.storage.github.ssh.knownHosts`); const privateKeySource = readFileSource(record(privateKey.sourceRef, `${servicePath}.storage.github.ssh.privateKey.sourceRef`), `${servicePath}.storage.github.ssh.privateKey.sourceRef`); const knownHostsSource = readFileSource(record(knownHosts.sourceRef, `${servicePath}.storage.github.ssh.knownHosts.sourceRef`), `${servicePath}.storage.github.ssh.knownHosts.sourceRef`); return { primary, github: { repo: required(github.repo, `${servicePath}.storage.github.repo`), sshUrl: required(github.sshUrl, `${servicePath}.storage.github.sshUrl`), branch: required(github.branch, `${servicePath}.storage.github.branch`), basePath: required(github.basePath, `${servicePath}.storage.github.basePath`), worktreePath: required(github.worktreePath, `${servicePath}.storage.github.worktreePath`), commitMessagePrefix: required(github.commitMessagePrefix, `${servicePath}.storage.github.commitMessagePrefix`), author: { name: required(record(github.author, `${servicePath}.storage.github.author`).name, `${servicePath}.storage.github.author.name`), email: required(record(github.author, `${servicePath}.storage.github.author`).email, `${servicePath}.storage.github.author.email`), }, ssh: { secretName: required(ssh.secretName, `${servicePath}.storage.github.ssh.secretName`), mountPath: required(ssh.mountPath, `${servicePath}.storage.github.ssh.mountPath`), privateKeyKey: required(privateKey.secretKey, `${servicePath}.storage.github.ssh.privateKey.secretKey`), knownHostsKey: required(knownHosts.secretKey, `${servicePath}.storage.github.ssh.knownHosts.secretKey`), privateKeySource, knownHostsSource, fingerprint: sha(`${privateKeySource.fingerprint}\n${knownHostsSource.fingerprint}`), }, }, }; } function managedMicroservice(service, servicePath, id, name, description) { return { id, name, providerId: required(target.id, "target.id"), description, repository: { url: required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`), commitId: required(runtime.commit, "runtime.commit"), dockerfile: required(record(service.repository, `${servicePath}.repository`).dockerfile, `${servicePath}.repository.dockerfile`), composeFile: required(runtime.deployRef, "runtime.deployRef"), composeService: required(service.deploymentName, `${servicePath}.deploymentName`), containerName: required(service.containerName, `${servicePath}.containerName`), }, backend: { nodeBaseUrl: `http://${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local:${required(String(service.containerPort), `${servicePath}.containerPort`)}`, nodeBindHost: `${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local`, nodePort: positiveInteger(service.containerPort, `${servicePath}.containerPort`), proxyMode: "cluster-service-http", frontendOnly: true, public: false, allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"], allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"], healthPath: required(service.healthPath, `${servicePath}.healthPath`), timeoutMs: 30000, }, development: { providerId: required(target.id, "target.id"), sshPassthrough: true, worktreePath: required(record(service.repository, `${servicePath}.repository`).worktreePath, `${servicePath}.repository.worktreePath`), }, frontend: { route: required(record(service.frontend, `${servicePath}.frontend`).route, `${servicePath}.frontend.route`), integrated: record(service.frontend, `${servicePath}.frontend`).integrated !== false, }, deployment: { mode: "internal-sidecar", namespace, expectedNodeIds: [required(target.id, "target.id")], activeNodeId: required(target.id, "target.id"), }, }; } const decisionCenterDatabase = readServiceDatabase(decisionCenter, "services.decisionCenter"); const decisionCenterStorage = readServiceStorage(decisionCenter, "services.decisionCenter"); const todoNoteDatabase = readServiceDatabase(todoNote, "services.todoNote"); const todoNoteStorage = readServiceStorage(todoNote, "services.todoNote"); const configuredMicroservices = JSON.parse(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson")); if (!Array.isArray(configuredMicroservices)) throw new Error("runtimeConfig.microservicesJson must encode an array"); const managedMicroservices = [ ...(decisionCenter === null ? [] : [managedMicroservice(decisionCenter, "services.decisionCenter", "decision-center", "Decision Center", "NC01 YAML-first Decision Center for decisions, requirements, meetings, and work diaries.")]), ...(todoNote === null ? [] : [managedMicroservice(todoNote, "services.todoNote", "todo-note", "Todo Note", "NC01 YAML-first Todo Note backed by the shared decision-center-data GitHub repository.")]), ]; const managedIds = new Set(managedMicroservices.map((service) => service.id)); const effectiveMicroservicesJson = JSON.stringify([ ...configuredMicroservices.filter((service) => typeof service !== "object" || service === null || !managedIds.has(service.id)), ...managedMicroservices, ]); const runtimeConfigFingerprint = sha(JSON.stringify({ ...runtimeConfig, microservicesJson: effectiveMicroservicesJson })); 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(authCredential.username)} UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: ${yamlScalar(required(runtimeConfig.sshClientRouteAllowlist, "runtimeConfig.sshClientRouteAllowlist"))} MICROSERVICES_JSON: ${yamlScalar(effectiveMicroservicesJson)} NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))} no_proxy: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}`); const serviceDatabaseSecrets = new Map(); for (const databaseSecret of [decisionCenterDatabase, todoNoteDatabase].filter(Boolean)) { const existing = serviceDatabaseSecrets.get(databaseSecret.secretName); if (existing !== undefined && (existing.secretKey !== databaseSecret.secretKey || existing.value !== databaseSecret.value)) { throw new Error(`conflicting managed service database Secret: ${databaseSecret.secretName}`); } serviceDatabaseSecrets.set(databaseSecret.secretName, databaseSecret); } for (const databaseSecret of serviceDatabaseSecrets.values()) { docs.push(`apiVersion: v1 kind: Secret metadata: name: ${databaseSecret.secretName} namespace: ${namespace} labels: ${indent(labels(config), 4)} annotations: unidesk.ai/source-ref: ${yamlScalar(databaseSecret.sourceRefPath)} unidesk.ai/fingerprint: ${yamlScalar(databaseSecret.fingerprint)} type: Opaque stringData: ${databaseSecret.secretKey}: ${yamlScalar(databaseSecret.value)}`); } const serviceGitSecrets = new Map(); for (const storage of [decisionCenterStorage, todoNoteStorage]) { if (storage?.github === undefined) continue; const gitSecret = storage.github.ssh; const existing = serviceGitSecrets.get(gitSecret.secretName); if (existing !== undefined && ( existing.privateKeyKey !== gitSecret.privateKeyKey || existing.knownHostsKey !== gitSecret.knownHostsKey || existing.privateKeySource.value !== gitSecret.privateKeySource.value || existing.knownHostsSource.value !== gitSecret.knownHostsSource.value )) { throw new Error(`conflicting managed service Git SSH Secret: ${gitSecret.secretName}`); } serviceGitSecrets.set(gitSecret.secretName, gitSecret); } for (const gitSecret of serviceGitSecrets.values()) { docs.push(`apiVersion: v1 kind: Secret metadata: name: ${gitSecret.secretName} namespace: ${namespace} labels: ${indent(labels(config), 4)} annotations: unidesk.ai/private-key-source-ref: ${yamlScalar(gitSecret.privateKeySource.sourceRefPath)} unidesk.ai/known-hosts-source-ref: ${yamlScalar(gitSecret.knownHostsSource.sourceRefPath)} unidesk.ai/fingerprint: ${yamlScalar(gitSecret.fingerprint)} type: Opaque stringData: ${gitSecret.privateKeyKey}: ${yamlScalar(gitSecret.privateKeySource.value)} ${gitSecret.knownHostsKey}: ${yamlScalar(gitSecret.knownHostsSource.value)}`); } 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: {}`); function managedStorageEnv(envPrefix, storage) { const lines = [ `- name: ${envPrefix}_STORAGE_PRIMARY`, ` value: ${yamlScalar(storage?.primary ?? "postgres")}`, ]; if (storage?.github === undefined) return lines.join("\n"); lines.push( `- name: ${envPrefix}_GIT_REPO`, ` value: ${yamlScalar(storage.github.repo)}`, `- name: ${envPrefix}_GIT_REPO_SSH_URL`, ` value: ${yamlScalar(storage.github.sshUrl)}`, `- name: ${envPrefix}_GIT_BRANCH`, ` value: ${yamlScalar(storage.github.branch)}`, `- name: ${envPrefix}_GIT_BASE_PATH`, ` value: ${yamlScalar(storage.github.basePath)}`, `- name: ${envPrefix}_GIT_WORKTREE`, ` value: ${yamlScalar(storage.github.worktreePath)}`, `- name: ${envPrefix}_GIT_AUTHOR_NAME`, ` value: ${yamlScalar(storage.github.author.name)}`, `- name: ${envPrefix}_GIT_AUTHOR_EMAIL`, ` value: ${yamlScalar(storage.github.author.email)}`, `- name: ${envPrefix}_GIT_COMMIT_PREFIX`, ` value: ${yamlScalar(storage.github.commitMessagePrefix)}`, "- name: GIT_SSH_COMMAND", ` value: ${yamlScalar(`ssh -i ${storage.github.ssh.mountPath}/${storage.github.ssh.privateKeyKey} -o UserKnownHostsFile=${storage.github.ssh.mountPath}/${storage.github.ssh.knownHostsKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes`)}`, ); return lines.join("\n"); } function appendManagedServiceDeployment({ service, servicePath, serviceId, imageKey, envPrefix, databaseConfig, storageConfig }) { if (service === null) return; if (deliveryServiceRef === servicePath) return; if (databaseConfig === null) throw new Error(`${servicePath}.database is required`); const github = storageConfig?.github; docs.push(`apiVersion: v1 kind: Service metadata: name: ${required(service.serviceName, `${servicePath}.serviceName`)} namespace: ${namespace} labels: ${indent(labels(config), 4)} app.kubernetes.io/name: ${serviceId} spec: type: ClusterIP selector: app.kubernetes.io/name: ${serviceId} ports: - name: http port: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)} targetPort: http`); docs.push(`apiVersion: apps/v1 kind: Deployment metadata: name: ${required(service.deploymentName, `${servicePath}.deploymentName`)} namespace: ${namespace} labels: ${indent(labels(config), 4)} app.kubernetes.io/name: ${serviceId} spec: replicas: ${positiveInteger(service.replicas, `${servicePath}.replicas`)} selector: matchLabels: app.kubernetes.io/name: ${serviceId} template: metadata: labels: app.kubernetes.io/name: ${serviceId} app.kubernetes.io/part-of: unidesk annotations: unidesk.ai/secret-fingerprint: ${yamlScalar(databaseConfig.fingerprint)} unidesk.ai/storage-secret-fingerprint: ${yamlScalar(github?.ssh.fingerprint ?? "")} unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)} spec: initContainers: - name: wait-database image: ${required(images.postgres, "images.postgres")} imagePullPolicy: IfNotPresent env: - name: DATABASE_URL valueFrom: secretKeyRef: name: ${databaseConfig.secretName} key: ${databaseConfig.secretKey} command: ["sh", "-ec", "db_url=\\"$(printf '%s' \\"$DATABASE_URL\\" | sed 's/[?&]uselibpqcompat=true//g')\\"; until psql \\"$db_url\\" -Atqc 'SELECT 1' >/dev/null 2>&1; do sleep 2; done"] containers: - name: ${required(service.containerName, `${servicePath}.containerName`)} image: ${required(images[imageKey], `images.${imageKey}`)} imagePullPolicy: IfNotPresent ports: - name: http containerPort: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)} env: - name: HOST value: "0.0.0.0" - name: PORT value: ${yamlScalar(service.containerPort)} - name: DATABASE_URL valueFrom: secretKeyRef: name: ${databaseConfig.secretName} key: ${databaseConfig.secretKey} - name: DATABASE_POOL_MAX value: ${yamlScalar(required(runtimeConfig.databasePoolMax, "runtimeConfig.databasePoolMax"))} - name: UNIDESK_DEPLOY_SERVICE_ID value: ${yamlScalar(serviceId)} - name: UNIDESK_DEPLOY_REPO value: ${yamlScalar(required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`))} - name: UNIDESK_DEPLOY_COMMIT value: ${yamlScalar(required(runtime.commit, "runtime.commit"))} - name: UNIDESK_DEPLOY_REQUESTED_COMMIT value: ${yamlScalar(required(runtime.commit, "runtime.commit"))} - name: LOG_FILE value: ${yamlScalar(required(service.logFile, `${servicePath}.logFile`))} ${indent(managedStorageEnv(envPrefix, storageConfig), 12)} volumeMounts: - name: logs mountPath: /var/log/unidesk ${github === undefined ? "" : indent(`- name: github-ssh mountPath: ${yamlScalar(github.ssh.mountPath)} readOnly: true`, 12)} readinessProbe: httpGet: path: ${required(service.healthPath, `${servicePath}.healthPath`)} port: http periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 18 livenessProbe: httpGet: path: /live port: http periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 startupProbe: httpGet: path: /live port: http periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 30 ${indent(resourceBlock(service.resources), 10)} volumes: - name: logs emptyDir: {} ${github === undefined ? "" : indent(`- name: github-ssh secret: secretName: ${github.ssh.secretName} defaultMode: 0400`, 8)}`); } appendManagedServiceDeployment({ service: decisionCenter, servicePath: "services.decisionCenter", serviceId: "decision-center", imageKey: "decisionCenter", envPrefix: "DECISION_CENTER", databaseConfig: decisionCenterDatabase, storageConfig: decisionCenterStorage, }); appendManagedServiceDeployment({ service: todoNote, servicePath: "services.todoNote", serviceId: "todo-note", imageKey: "todoNote", envPrefix: "TODO_NOTE", databaseConfig: todoNoteDatabase, storageConfig: todoNoteStorage, }); 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`);