import * as k8s from "@kubernetes/client-node"; import type { PiecedTenant, PiecedTenantSpec } from "@/types"; import { readFileSync } from "fs"; const kc = new k8s.KubeConfig(); if (process.env.KUBERNETES_SERVICE_HOST) { kc.loadFromCluster(); } else { kc.loadFromDefault(); } const API_VERSION = "pieced.ch/v1alpha1"; const PLURAL = "piecedtenants"; // Raw K8s API client — avoids @kubernetes/client-node API surface instability // across versions. The REST API itself is stable. function getBaseUrl(): string { const cluster = kc.getCurrentCluster(); if (!cluster) throw new Error("No active K8s cluster in kubeconfig"); return cluster.server; } function getAuthHeaders(): Record { // In-cluster: read SA token if (process.env.KUBERNETES_SERVICE_HOST) { const token = readFileSync( "/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8" ); return { Authorization: `Bearer ${token}` }; } // Local dev: extract token from kubeconfig current user const user = kc.getCurrentUser(); if (user?.token) { return { Authorization: `Bearer ${user.token}` }; } return {}; } async function k8sRequest( path: string, method: string = "GET", body?: unknown ): Promise { const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}${path}`; const res = await fetch(url, { method, headers: { Accept: "application/json", "Content-Type": "application/json", ...getAuthHeaders(), }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const text = await res.text(); const err = new Error(`K8s ${method} ${path}: ${res.status} ${text}`); (err as any).statusCode = res.status; throw err; } return res.json() as Promise; } export async function listTenants(): Promise { const result = await k8sRequest<{ items: PiecedTenant[] }>(""); return result.items ?? []; } export async function getTenant(name: string): Promise { try { return await k8sRequest(`/${name}`); } catch (e: any) { if (e.statusCode === 404) return null; throw e; } } export async function createTenant( name: string, spec: PiecedTenantSpec, labels?: Record ): Promise { return k8sRequest("", "POST", { apiVersion: API_VERSION, kind: "PiecedTenant", metadata: { name, labels }, spec, }); } export async function updateTenantSpec( name: string, spec: Partial ): Promise { const existing = await getTenant(name); if (!existing) throw new Error(`Tenant ${name} not found`); return k8sRequest(`/${name}`, "PUT", { ...existing, spec: { ...existing.spec, ...spec }, }); } export async function deleteTenant(name: string): Promise { await k8sRequest(`/${name}`, "DELETE"); } export async function patchTenantSpec( name: string, spec: Partial ): Promise { const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}/${name}`; const res = await fetch(url, { method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/merge-patch+json", ...getAuthHeaders(), }, body: JSON.stringify({ spec }), }); if (!res.ok) { const text = await res.text(); const err = new Error(`K8s PATCH /${name}: ${res.status} ${text}`); (err as any).statusCode = res.status; throw err; } return res.json() as Promise; } /** * Set or clear an annotation on a PiecedTenant CR. * * Pass `value=null` to remove the annotation. K8s merge-patch removes * a key when its value is null in the patch — that's exactly the * semantic we want. * * Used by the resume-request flow (Bug 37a): the portal sets * `pieced.ch/resume-request-pending` when a customer creates a * resume request, and clears it when the request transitions to a * terminal state. The operator reads this annotation to pause its * 60-day deletion timer while a resume request is in flight. * * Annotations are namespaced informally — we use `pieced.ch/...` for * everything we own, mirroring the labels. */ export async function setTenantAnnotation( name: string, key: string, value: string | null ): Promise { const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}/${name}`; const res = await fetch(url, { method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/merge-patch+json", ...getAuthHeaders(), }, body: JSON.stringify({ metadata: { annotations: { [key]: value } }, }), }); if (!res.ok) { const text = await res.text(); const err = new Error(`K8s annotate /${name}: ${res.status} ${text}`); (err as any).statusCode = res.status; throw err; } return res.json() as Promise; } // --------------------------------------------------------------------------- // OpenClaw config ConfigMap helpers (admin-only feature: per-tenant version // override + platform default). // // The ConfigMap lives in the operator's namespace (`pieced-system`). The // portal's ServiceAccount needs `get/patch` on configmaps in that namespace // — rules added in the gitops repo. // // Tag-only by design — see operator notes for rationale. // --------------------------------------------------------------------------- const OPENCLAW_CONFIGMAP_NAME = "pieced-openclaw-config"; /** * Operator namespace. Reads the env var so the portal can be deployed in * non-default namespaces without code changes; defaults to "pieced-system" * matching the operator's chart default. */ function getOperatorNamespace(): string { return process.env.OPERATOR_NAMESPACE ?? "pieced-system"; } export interface OpenClawDefaults { /** Image tag (e.g. "2026.4.22"). Empty string means unset. */ defaultTag: string; } /** * Read the platform-default OpenClaw image tag. Returns empty string * if unset, and `{ defaultTag: "" }` if the ConfigMap doesn't exist yet * — the operator's built-in fallback is invisible to the portal by * design (we don't want the UI to claim "current default: 2026.x" when * it's actually the operator binary's baked-in version; that would be * misleading once the binary updates). */ export async function getOpenClawDefaults(): Promise { const ns = getOperatorNamespace(); const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`; const res = await fetch(url, { headers: { Accept: "application/json", ...getAuthHeaders() }, }); if (res.status === 404) { return { defaultTag: "" }; } if (!res.ok) { const text = await res.text(); const err = new Error( `K8s GET configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}` ); (err as any).statusCode = res.status; throw err; } const cm = (await res.json()) as { data?: Record }; return { defaultTag: cm.data?.defaultTag ?? "" }; } /** * Update the platform-default OpenClaw image tag. Empty string clears * the field (operator falls back to its built-in default). * * Creates the ConfigMap if it doesn't exist (PATCH on missing resource * 404s; we retry as POST). Keeps the admin UI usable on a fresh install * where the helm-shipped CM was deleted or never created. */ export async function setOpenClawDefaults( defaults: OpenClawDefaults ): Promise { const ns = getOperatorNamespace(); const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`; const patch = { data: { defaultTag: defaults.defaultTag } }; const res = await fetch(url, { method: "PATCH", headers: { Accept: "application/json", "Content-Type": "application/merge-patch+json", ...getAuthHeaders(), }, body: JSON.stringify(patch), }); if (res.status === 404) { const createUrl = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps`; const createRes = await fetch(createUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", ...getAuthHeaders(), }, body: JSON.stringify({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: OPENCLAW_CONFIGMAP_NAME, namespace: ns }, data: patch.data, }), }); if (!createRes.ok) { const text = await createRes.text(); throw new Error( `K8s POST configmap ${OPENCLAW_CONFIGMAP_NAME}: ${createRes.status} ${text}` ); } return defaults; } if (!res.ok) { const text = await res.text(); throw new Error( `K8s PATCH configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}` ); } return defaults; }