feat: deploy decision center on nc01 k8s

This commit is contained in:
Codex
2026-07-09 17:55:20 +02:00
parent ef67a8d56c
commit 0f14bc16a5
6 changed files with 590 additions and 8 deletions
+289 -3
View File
@@ -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<DecisionRecordType, "meeting">;
@@ -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<DecisionRecordType>(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
const requirementTypeValues = new Set<RequirementRecordType>(["decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
const levelValues = new Set<DecisionRecordLevel>(["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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 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 readYamlConfig(path = hostK8sConfigPath): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<Record<string, string>> {
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<string, unknown> {
const options = deploymentOptions(args);
const summary = decisionDeploymentConfigSummary(options.configPath);
let render: Record<string, unknown>;
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, unknown> | 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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<unknown> {
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<unknown>,
): Promise<unknown> {
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");
}
+4 -2
View File
@@ -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 <markdown-file> [--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.",
};
}