From 0f14bc16a5e08d2a0f60a16f2eeec4107083d092 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 17:55:20 +0200 Subject: [PATCH] feat: deploy decision center on nc01 k8s --- config/platform-db/postgres-nc01.yaml | 53 ++++ config/unidesk-host-k8s.yaml | 27 ++ .../native/deploy/render-unidesk-host-k8s.mjs | 218 ++++++++++++- scripts/src/decision-center.ts | 292 +++++++++++++++++- scripts/src/help.ts | 6 +- .../backend-core/src/microservice-proxy.ts | 2 +- 6 files changed, 590 insertions(+), 8 deletions(-) diff --git a/config/platform-db/postgres-nc01.yaml b/config/platform-db/postgres-nc01.yaml index 9735d0d2..4aee2eca 100644 --- a/config/platform-db/postgres-nc01.yaml +++ b/config/platform-db/postgres-nc01.yaml @@ -103,6 +103,16 @@ postgres: user: agentrun_v02 address: 10.42.0.0/16 method: scram-sha-256 + - type: hostssl + database: decision_center + user: decision_center + address: 10.42.0.0/16 + method: scram-sha-256 + - type: hostssl + database: postgres + user: decision_center + address: 10.42.0.0/16 + method: scram-sha-256 secrets: source: master-local @@ -122,6 +132,20 @@ secrets: AGENTRUN_NC01_V02_DB_NAME: agentrun_v02 randomHex: AGENTRUN_NC01_V02_DB_PASSWORD: 32 + - name: decision-center-nc01-db-credentials + sourceRef: decision-center/nc01-db.env + type: env + requiredKeys: + - DECISION_CENTER_NC01_DB_USER + - DECISION_CENTER_NC01_DB_PASSWORD + - DECISION_CENTER_NC01_DB_NAME + createIfMissing: + enabled: true + values: + DECISION_CENTER_NC01_DB_USER: decision_center + DECISION_CENTER_NC01_DB_NAME: decision_center + randomHex: + DECISION_CENTER_NC01_DB_PASSWORD: 32 objects: roles: @@ -134,12 +158,26 @@ objects: createdb: false createrole: false superuser: false + - name: decision_center + passwordRef: + sourceRef: decision-center/nc01-db.env + key: DECISION_CENTER_NC01_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: agentrun_v02 owner: agentrun_v02 encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: decision_center + owner: decision_center + encoding: UTF8 + locale: C.UTF-8 + extensions: [] exports: connectionStrings: @@ -158,6 +196,21 @@ exports: - scope: agentrun-v02 secret: agentrun-v02-mgr-db key: DATABASE_URL + - name: decision-center-nc01-database-url + sourceSecretRef: decision-center/nc01-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(DECISION_CENTER_NC01_DB_USER):$(DECISION_CENTER_NC01_DB_PASSWORD)@$(PGHOST):5432/$(DECISION_CENTER_NC01_DB_NAME)?sslmode=require&uselibpqcompat=true + variables: + PGHOST: 10.42.0.1 + writeToSecretSource: + sourceRef: decision-center/nc01-db.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: decision-center + secret: decision-center-runtime-secrets + key: DATABASE_URL backup: logicalDump: diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index abcd018b..f76cc2aa 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -21,6 +21,7 @@ images: postgres: postgres:16-alpine backendCore: unidesk-backend-core:host-k8s frontend: unidesk-frontend:host-k8s + decisionCenter: unidesk-decision-center:host-k8s database: mode: host-native @@ -67,6 +68,32 @@ services: memory: 128Mi limits: memory: 512Mi + decisionCenter: + deploymentName: decision-center + serviceName: decision-center + containerName: decision-center + containerPort: 4277 + healthPath: /health + logFile: /var/log/unidesk/decision-center.jsonl + database: + sourceRef: + path: .state/secrets/decision-center/nc01-db.env + key: DATABASE_URL + secretName: decision-center-runtime-secrets + secretKey: DATABASE_URL + repository: + url: https://github.com/pikasTech/unidesk + dockerfile: src/components/microservices/decision-center/Dockerfile + worktreePath: /root/unidesk + frontend: + route: /apps/decision-center + integrated: true + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + memory: 512Mi runtimeConfig: heartBeatTimeoutMs: "30000" diff --git a/scripts/native/deploy/render-unidesk-host-k8s.mjs b/scripts/native/deploy/render-unidesk-host-k8s.mjs index 2b62d4bc..f2863af7 100755 --- a/scripts/native/deploy/render-unidesk-host-k8s.mjs +++ b/scripts/native/deploy/render-unidesk-host-k8s.mjs @@ -82,6 +82,28 @@ function envFromConfig(configName, keyName, envName) { ].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"); @@ -105,6 +127,7 @@ 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 runtimeConfig = record(config.runtimeConfig, "runtimeConfig"); const runtimeAuth = record(config.runtimeAuth, "runtimeAuth"); const runtimeAuthSourceRef = record(runtimeAuth.sourceRef, "runtimeAuth.sourceRef"); @@ -128,7 +151,6 @@ 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"); @@ -140,6 +162,74 @@ if (secrets[secretKeys.databaseUrl] !== `postgres://${secrets[secretKeys.postgre throw new Error(`DATABASE_URL in ${sourcePath} must target ${dbHost}:${dbPort}/${dbName}`); } +function readDecisionCenterDatabase(decision) { + if (decision === null) return null; + const database = record(decision.database, "services.decisionCenter.database"); + const sourceRef = record(database.sourceRef, "services.decisionCenter.database.sourceRef"); + const sourcePath = resolve(required(sourceRef.path, "services.decisionCenter.database.sourceRef.path")); + const sourceKey = required(sourceRef.key, "services.decisionCenter.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, "services.decisionCenter.database.sourceRef.path"), + sourceKey, + secretName: required(database.secretName, "services.decisionCenter.database.secretName"), + secretKey: required(database.secretKey, "services.decisionCenter.database.secretKey"), + value: removeUrlSearchParam(value, "uselibpqcompat"), + fingerprint: sha(`${sourceKey}=${removeUrlSearchParam(value, "uselibpqcompat")}`), + }; +} + +const decisionCenterDatabase = readDecisionCenterDatabase(decisionCenter); +const effectiveMicroservicesJson = decisionCenter === null + ? required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson") + : JSON.stringify([ + { + id: "decision-center", + name: "Decision Center", + providerId: required(target.id, "target.id"), + description: "Decision Center is managed by the NC01 YAML-first k8s runtime for UniDesk decisions, requirements, meetings, and work diaries.", + repository: { + url: required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.repository.url"), + commitId: required(runtime.commit, "runtime.commit"), + dockerfile: required(record(decisionCenter.repository, "services.decisionCenter.repository").dockerfile, "services.decisionCenter.repository.dockerfile"), + composeFile: required(runtime.deployRef, "runtime.deployRef"), + composeService: required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName"), + containerName: required(decisionCenter.containerName, "services.decisionCenter.containerName"), + }, + backend: { + nodeBaseUrl: `http://${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local:${required(String(decisionCenter.containerPort), "services.decisionCenter.containerPort")}`, + nodeBindHost: `${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local`, + nodePort: positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort"), + proxyMode: "cluster-service-http", + frontendOnly: true, + public: false, + allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"], + allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"], + healthPath: required(decisionCenter.healthPath, "services.decisionCenter.healthPath"), + timeoutMs: 30000, + }, + development: { + providerId: required(target.id, "target.id"), + sshPassthrough: true, + worktreePath: required(record(decisionCenter.repository, "services.decisionCenter.repository").worktreePath, "services.decisionCenter.repository.worktreePath"), + }, + frontend: { + route: required(record(decisionCenter.frontend, "services.decisionCenter.frontend").route, "services.decisionCenter.frontend.route"), + integrated: record(decisionCenter.frontend, "services.decisionCenter.frontend").integrated !== false, + }, + deployment: { + mode: "internal-sidecar", + namespace, + expectedNodeIds: [required(target.id, "target.id")], + activeNodeId: required(target.id, "target.id"), + }, + }, + ]); +const runtimeConfigFingerprint = sha(JSON.stringify({ ...runtimeConfig, microservicesJson: effectiveMicroservicesJson })); + const docs = []; docs.push(`apiVersion: v1 kind: Namespace @@ -186,9 +276,25 @@ data: 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(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson"))} + MICROSERVICES_JSON: ${yamlScalar(effectiveMicroservicesJson)} NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))} no_proxy: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}`); +if (decisionCenter !== null && decisionCenterDatabase !== null) { +docs.push(`apiVersion: v1 +kind: Secret +metadata: + name: ${decisionCenterDatabase.secretName} + namespace: ${namespace} + labels: +${indent(labels(config), 4)} + app.kubernetes.io/name: decision-center + annotations: + unidesk.ai/source-ref: ${yamlScalar(decisionCenterDatabase.sourceRefPath)} + unidesk.ai/fingerprint: ${yamlScalar(decisionCenterDatabase.fingerprint)} +type: Opaque +stringData: + ${decisionCenterDatabase.secretKey}: ${yamlScalar(decisionCenterDatabase.value)}`); +} if (databaseMode === "k8s-postgres") { docs.push(`apiVersion: v1 kind: PersistentVolumeClaim @@ -409,6 +515,114 @@ ${indent(resourceBlock(backend.resources), 10)} volumes: - name: logs emptyDir: {}`); +if (decisionCenter !== null) { +docs.push(`apiVersion: v1 +kind: Service +metadata: + name: ${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")} + namespace: ${namespace} + labels: +${indent(labels(config), 4)} + app.kubernetes.io/name: decision-center +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: decision-center + ports: + - name: http + port: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")} + targetPort: http`); +docs.push(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName")} + namespace: ${namespace} + labels: +${indent(labels(config), 4)} + app.kubernetes.io/name: decision-center +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: decision-center + template: + metadata: + labels: + app.kubernetes.io/name: decision-center + app.kubernetes.io/part-of: unidesk + annotations: + unidesk.ai/secret-fingerprint: ${yamlScalar(decisionCenterDatabase?.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: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")} + key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.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(decisionCenter.containerName, "services.decisionCenter.containerName")} + image: ${required(images.decisionCenter, "images.decisionCenter")} + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")} + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: ${yamlScalar(decisionCenter.containerPort)} + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")} + key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.secretKey")} + - name: DATABASE_POOL_MAX + value: "2" + - name: UNIDESK_DEPLOY_SERVICE_ID + value: "decision-center" + - name: UNIDESK_DEPLOY_REPO + value: ${yamlScalar(required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.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(decisionCenter.logFile, "services.decisionCenter.logFile"))} + volumeMounts: + - name: logs + mountPath: /var/log/unidesk + readinessProbe: + httpGet: + path: ${required(decisionCenter.healthPath, "services.decisionCenter.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(decisionCenter.resources), 10)} + volumes: + - name: logs + emptyDir: {}`); +} docs.push(`apiVersion: v1 kind: Service metadata: diff --git a/scripts/src/decision-center.ts b/scripts/src/decision-center.ts index 14e12235..53bae0a4 100644 --- a/scripts/src/decision-center.ts +++ b/scripts/src/decision-center.ts @@ -1,8 +1,10 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { type UniDeskConfig, repoRoot } from "./config"; -import { coreInternalFetch } from "./microservices"; import { runCommand } from "./command"; +import { coreInternalFetch } from "./microservices"; +import { capture, compactCapture, parseJsonOutput, shQuote } from "./platform-infra-ops-library"; type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "internal_goal" | "blocker" | "debt" | "experiment"; type RequirementRecordType = Exclude; @@ -12,6 +14,7 @@ type DecisionDocumentType = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" type DecisionDocumentPriority = "P0" | "P1" | "P2" | "P3"; const serviceId = "decision-center"; +const hostK8sConfigPath = "config/unidesk-host-k8s.yaml"; const typeValues = new Set(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]); const requirementTypeValues = new Set(["decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]); const levelValues = new Set(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]); @@ -30,6 +33,288 @@ function optionValue(args: string[], names: string[]): string | undefined { return undefined; } +function hasFlag(args: string[], name: string): boolean { + return args.includes(name); +} + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} + +function stringField(obj: Record, 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, 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 readYamlConfig(path = hostK8sConfigPath): Record { + return asRecord(Bun.YAML.parse(readFileSync(resolve(repoRoot, path), "utf8")) as unknown, path); +} + +function sha256Short(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +function secretSourceSummary(sourcePath: string, key: string): Record { + const absolute = resolve(repoRoot, sourcePath); + if (!existsSync(absolute)) return { sourceRef: sourcePath, key, present: false, fingerprint: null }; + const text = readFileSync(absolute, "utf8"); + const line = text.split(/\r?\n/u).find((item) => item.startsWith(`${key}=`)) ?? ""; + const value = line.length > key.length + 1 ? line.slice(key.length + 1) : ""; + return { + sourceRef: sourcePath, + key, + present: value.length > 0, + fingerprint: value.length > 0 ? `sha256:${sha256Short(`${key}=${value}`)}` : null, + }; +} + +function decisionDeploymentConfigSummary(configPath = hostK8sConfigPath): Record { + const parsed = readYamlConfig(configPath); + const target = asRecord(parsed.target, `${configPath}.target`); + const runtime = asRecord(parsed.runtime, `${configPath}.runtime`); + const images = asRecord(parsed.images, `${configPath}.images`); + const services = asRecord(parsed.services, `${configPath}.services`); + const decision = asRecord(services.decisionCenter, `${configPath}.services.decisionCenter`); + const database = asRecord(decision.database, `${configPath}.services.decisionCenter.database`); + const sourceRef = asRecord(database.sourceRef, `${configPath}.services.decisionCenter.database.sourceRef`); + return { + configPath, + target: { + node: stringField(target, "id", `${configPath}.target`), + route: `${stringField(target, "id", `${configPath}.target`)}:k3s`, + namespace: stringField(target, "namespace", `${configPath}.target`), + }, + runtime: { + deployRef: stringField(runtime, "deployRef", `${configPath}.runtime`), + repository: stringField(runtime, "repository", `${configPath}.runtime`), + commit: stringField(runtime, "commit", `${configPath}.runtime`), + }, + image: stringField(images, "decisionCenter", `${configPath}.images`), + workload: { + deployment: stringField(decision, "deploymentName", `${configPath}.services.decisionCenter`), + service: stringField(decision, "serviceName", `${configPath}.services.decisionCenter`), + container: stringField(decision, "containerName", `${configPath}.services.decisionCenter`), + port: numberField(decision, "containerPort", `${configPath}.services.decisionCenter`), + healthPath: stringField(decision, "healthPath", `${configPath}.services.decisionCenter`), + }, + database: { + secret: stringField(database, "secretName", `${configPath}.services.decisionCenter.database`), + key: stringField(database, "secretKey", `${configPath}.services.decisionCenter.database`), + source: secretSourceSummary( + stringField(sourceRef, "path", `${configPath}.services.decisionCenter.database.sourceRef`), + stringField(sourceRef, "key", `${configPath}.services.decisionCenter.database.sourceRef`), + ), + }, + }; +} + +function renderDecisionDeploymentManifest(configPath = hostK8sConfigPath): string { + const result = runCommand(["bun", "scripts/native/deploy/render-unidesk-host-k8s.mjs", configPath], repoRoot, { timeoutMs: 30_000 }); + if (result.exitCode !== 0) throw new Error(`render ${configPath} failed: ${result.stderr || result.stdout}`); + return result.stdout; +} + +function manifestObjects(manifest: string): Array> { + return manifest + .split(/^---$/mu) + .map((doc) => ({ + kind: doc.match(/^kind:\s*(\S+)/mu)?.[1] ?? "", + name: doc.match(/^metadata:\n(?:.*\n)*?\s{2}name:\s*(\S+)/mu)?.[1] ?? "", + namespace: doc.match(/^metadata:\n(?:.*\n)*?\s{2}namespace:\s*(\S+)/mu)?.[1] ?? "", + })) + .filter((item) => item.kind.length > 0 && item.name.length > 0); +} + +function deploymentOptions(args: string[]): { configPath: string; confirm: boolean; dryRun: boolean; wait: boolean; full: boolean; raw: boolean } { + return { + configPath: optionValue(args, ["--config"]) ?? hostK8sConfigPath, + confirm: hasFlag(args, "--confirm"), + dryRun: hasFlag(args, "--dry-run"), + wait: !hasFlag(args, "--no-wait"), + full: hasFlag(args, "--full"), + raw: hasFlag(args, "--raw"), + }; +} + +function deploymentPlan(args: string[]): Record { + const options = deploymentOptions(args); + const summary = decisionDeploymentConfigSummary(options.configPath); + let render: Record; + try { + const manifest = renderDecisionDeploymentManifest(options.configPath); + render = { + ok: true, + manifestSha: `sha256:${sha256Short(manifest)}`, + objects: manifestObjects(manifest), + }; + } catch (error) { + render = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + return { + ok: render.ok === true, + action: "decision-center-deployment-plan", + mutation: false, + config: summary, + render, + next: { + dryRun: `bun scripts/cli.ts decision deployment apply --config ${options.configPath} --dry-run`, + apply: `bun scripts/cli.ts decision deployment apply --config ${options.configPath} --confirm`, + status: `bun scripts/cli.ts decision deployment status --config ${options.configPath}`, + }, + }; +} + +function deploymentRender(args: string[]): Record | string { + const options = deploymentOptions(args); + const manifest = renderDecisionDeploymentManifest(options.configPath); + if (options.raw || options.full) return manifest; + return { + ok: true, + action: "decision-center-deployment-render", + mutation: false, + config: decisionDeploymentConfigSummary(options.configPath), + manifestSha: `sha256:${sha256Short(manifest)}`, + objects: manifestObjects(manifest), + disclosure: "Use --raw or --full to print the full manifest.", + }; +} + +function applyScript(namespace: string, manifest: string, dryRun: boolean, wait: boolean): string { + const manifestB64 = Buffer.from(manifest, "utf8").toString("base64"); + const applyArgs = dryRun ? "apply --dry-run=server -f" : "apply -f"; + return `set -eu +ns=${shQuote(namespace)} +tmp="$(mktemp /tmp/unidesk-decision-center.XXXXXX.yaml)" +cleanup() { rm -f "$tmp"; } +trap cleanup EXIT +printf %s ${shQuote(manifestB64)} | base64 -d > "$tmp" +kubectl ${applyArgs} "$tmp" +if [ ${dryRun ? "1" : "0"} -eq 0 ] && [ ${wait ? "1" : "0"} -eq 1 ]; then + kubectl -n "$ns" rollout status deployment/backend-core --timeout=45s + kubectl -n "$ns" rollout status deployment/frontend --timeout=45s + kubectl -n "$ns" rollout status deployment/decision-center --timeout=45s +fi +python3 - <<'PY' +import json +print(json.dumps({"ok": True, "applied": ${dryRun ? "False" : "True"}, "dryRun": ${dryRun ? "True" : "False"}})) +PY`; +} + +async function deploymentApply(config: UniDeskConfig, args: string[]): Promise> { + const options = deploymentOptions(args); + if (!options.confirm && !options.dryRun) throw new Error("decision deployment apply requires --confirm or --dry-run"); + const summary = decisionDeploymentConfigSummary(options.configPath); + const target = asRecord(summary.target, "deployment target"); + const manifest = renderDecisionDeploymentManifest(options.configPath); + const result = await capture(config, stringField(target, "route", "deployment.target"), ["sh"], applyScript(stringField(target, "namespace", "deployment.target"), manifest, options.dryRun, options.wait)); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && (parsed === null || parsed.ok === true), + action: "decision-center-deployment-apply", + mutation: !options.dryRun, + mode: options.dryRun ? "dry-run" : "confirmed", + config: summary, + manifestSha: `sha256:${sha256Short(manifest)}`, + remote: parsed ?? compactCapture(result, { full: options.full || result.exitCode !== 0 }), + next: { + status: `bun scripts/cli.ts decision deployment status --config ${options.configPath}`, + health: "bun scripts/cli.ts decision health", + }, + }; +} + +function statusScript(namespace: string, serviceName: string, port: number, healthPath: string): string { + return `set -eu +ns=${shQuote(namespace)} +svc=${shQuote(serviceName)} +port=${shQuote(String(port))} +health_path=${shQuote(healthPath)} +tmp="$(mktemp -d /tmp/unidesk-decision-center-status.XXXXXX)" +cleanup() { rm -rf "$tmp"; } +trap cleanup EXIT +kubectl -n "$ns" get deploy decision-center -o json > "$tmp/deploy.json" +kubectl -n "$ns" get svc "$svc" -o json > "$tmp/service.json" +kubectl -n "$ns" get pods -l app.kubernetes.io/name=decision-center -o json > "$tmp/pods.json" +kubectl get --raw "/api/v1/namespaces/$ns/services/$svc:$port/proxy$health_path" > "$tmp/health.json" 2>"$tmp/health.err" || true +python3 - "$tmp" <<'PY' +import json, pathlib, sys +base = pathlib.Path(sys.argv[1]) +def load(name, fallback=None): + try: + return json.loads((base / name).read_text()) + except Exception: + return fallback +deploy = load("deploy.json", {}) +svc = load("service.json", {}) +pods = load("pods.json", {"items": []}) +health = load("health.json", None) +conditions = {item.get("type"): item.get("status") for item in deploy.get("status", {}).get("conditions", [])} +ready = deploy.get("status", {}).get("readyReplicas", 0) == deploy.get("spec", {}).get("replicas", 1) +print(json.dumps({ + "ok": bool(ready and isinstance(health, dict) and health.get("ok") is True), + "ready": bool(ready), + "deployment": { + "name": deploy.get("metadata", {}).get("name"), + "image": (((deploy.get("spec", {}).get("template", {}).get("spec", {}).get("containers") or [{}])[0]).get("image")), + "readyReplicas": deploy.get("status", {}).get("readyReplicas", 0), + "replicas": deploy.get("spec", {}).get("replicas", 1), + "available": conditions.get("Available"), + }, + "service": {"name": svc.get("metadata", {}).get("name"), "clusterIP": svc.get("spec", {}).get("clusterIP")}, + "pods": [{"name": p.get("metadata", {}).get("name"), "phase": p.get("status", {}).get("phase")} for p in pods.get("items", [])], + "health": health, +})) +PY`; +} + +async function deploymentStatus(config: UniDeskConfig, args: string[]): Promise> { + const options = deploymentOptions(args); + const summary = decisionDeploymentConfigSummary(options.configPath); + const target = asRecord(summary.target, "deployment target"); + const workload = asRecord(summary.workload, "deployment workload"); + const result = await capture( + config, + stringField(target, "route", "deployment.target"), + ["sh"], + statusScript( + stringField(target, "namespace", "deployment.target"), + stringField(workload, "service", "deployment.workload"), + numberField(workload, "port", "deployment.workload"), + stringField(workload, "healthPath", "deployment.workload"), + ), + ); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && parsed?.ok === true, + action: "decision-center-deployment-status", + mutation: false, + config: summary, + remote: options.raw || options.full ? parsed ?? compactCapture(result, { full: true }) : parsed ?? compactCapture(result, { full: result.exitCode !== 0 }), + next: { + plan: `bun scripts/cli.ts decision deployment plan --config ${options.configPath}`, + apply: `bun scripts/cli.ts decision deployment apply --config ${options.configPath} --confirm`, + health: "bun scripts/cli.ts decision health", + }, + }; +} + +async function runDeploymentCommand(config: UniDeskConfig, args: string[]): Promise { + const [action = "plan"] = args; + if (action === "plan") return deploymentPlan(args.slice(1)); + if (action === "render") return deploymentRender(args.slice(1)); + if (action === "apply") return deploymentApply(config, args.slice(1)); + if (action === "status") return deploymentStatus(config, args.slice(1)); + throw new Error("decision deployment command must be one of: plan, render, apply, status"); +} + function optionValues(args: string[], names: string[]): string[] { const values: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -665,6 +950,7 @@ export async function runDecisionCenterCommandAsync( fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise, ): Promise { const [action = "list", id] = args; + if (action === "deployment" || action === "deploy") return runDeploymentCommand(_config, args.slice(1)); if (action === "diary") { const [diaryAction = "list", diaryId] = args.slice(1); if (diaryAction === "import") return importDiaryAsync(args.slice(2), fetcher); @@ -692,5 +978,5 @@ export async function runDecisionCenterCommandAsync( if (action === "list") return listRecordsAsync(args.slice(1), fetcher); if (action === "show") return showRecordAsync(id, fetcher); if (action === "health") return unwrapProxyResponse(await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/health`)); - throw new Error("decision command must be one of: upload, list, show, health, requirement, diary"); + throw new Error("decision command must be one of: upload, list, show, health, requirement, diary, deployment"); } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 3d558b28..c280fc80 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -278,7 +278,7 @@ function microserviceHelp(): unknown { function decisionHelp(): unknown { return { - command: "decision upload|list|show|health|diary|requirement", + command: "decision upload|list|show|health|diary|requirement|deployment", output: "json", usage: [ "bun scripts/cli.ts decision upload [--title text] [--type meeting|decision] [--doc-no DC-...]", @@ -287,8 +287,10 @@ function decisionHelp(): unknown { "bun scripts/cli.ts decision health", "bun scripts/cli.ts decision diary import|list|history|months|today|show|edit|upsert ...", "bun scripts/cli.ts decision requirement list|create|show|update|upsert ... [--doc-no DC-...] [--doc-type GOAL] [--doc-priority P0] [--signer text] [--issued-at ISO]", + "bun scripts/cli.ts decision deployment plan|render|status [--config config/unidesk-host-k8s.yaml]", + "bun scripts/cli.ts decision deployment apply --dry-run|--confirm [--config config/unidesk-host-k8s.yaml]", ], - description: "Operate Decision Center through the registered user-service proxy.", + description: "Operate Decision Center through the registered user-service proxy and manage its NC01 YAML-first k8s deployment.", }; } diff --git a/src/components/backend-core/src/microservice-proxy.ts b/src/components/backend-core/src/microservice-proxy.ts index c57b59c8..a94164c7 100644 --- a/src/components/backend-core/src/microservice-proxy.ts +++ b/src/components/backend-core/src/microservice-proxy.ts @@ -389,7 +389,7 @@ function rememberMicroserviceAvailability(serviceId: string, probe: Record