288 lines
8.7 KiB
TypeScript
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;
|
|
}
|