feat: deploy decision center on nc01 k8s
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user