Files
pieced-portal/src/lib/k8s.ts
admin b58bdadad4
All checks were successful
Build and Push / build (push) Successful in 1m52s
feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only)
2026-05-10 21:15:53 +02:00

288 lines
8.7 KiB
TypeScript

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<string, string> {
// 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<T>(
path: string,
method: string = "GET",
body?: unknown
): Promise<T> {
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<T>;
}
export async function listTenants(): Promise<PiecedTenant[]> {
const result = await k8sRequest<{ items: PiecedTenant[] }>("");
return result.items ?? [];
}
export async function getTenant(name: string): Promise<PiecedTenant | null> {
try {
return await k8sRequest<PiecedTenant>(`/${name}`);
} catch (e: any) {
if (e.statusCode === 404) return null;
throw e;
}
}
export async function createTenant(
name: string,
spec: PiecedTenantSpec,
labels?: Record<string, string>
): Promise<PiecedTenant> {
return k8sRequest<PiecedTenant>("", "POST", {
apiVersion: API_VERSION,
kind: "PiecedTenant",
metadata: { name, labels },
spec,
});
}
export async function updateTenantSpec(
name: string,
spec: Partial<PiecedTenantSpec>
): Promise<PiecedTenant> {
const existing = await getTenant(name);
if (!existing) throw new Error(`Tenant ${name} not found`);
return k8sRequest<PiecedTenant>(`/${name}`, "PUT", {
...existing,
spec: { ...existing.spec, ...spec },
});
}
export async function deleteTenant(name: string): Promise<void> {
await k8sRequest(`/${name}`, "DELETE");
}
export async function patchTenantSpec(
name: string,
spec: Partial<PiecedTenantSpec>
): Promise<PiecedTenant> {
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<PiecedTenant>;
}
/**
* 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<PiecedTenant> {
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<PiecedTenant>;
}
// ---------------------------------------------------------------------------
// 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<OpenClawDefaults> {
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<string, string> };
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<OpenClawDefaults> {
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;
}