Add initial Portal version

This commit is contained in:
2026-04-09 22:16:22 +02:00
commit d526c1ff4a
51 changed files with 10752 additions and 0 deletions

132
src/lib/k8s.ts Normal file
View File

@@ -0,0 +1,132 @@
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>;
}