mirror of
https://github.com/tale/headplane.git
synced 2026-07-28 00:28:56 +00:00
168 lines
4.8 KiB
TypeScript
168 lines
4.8 KiB
TypeScript
import { readdir, readFile } from "node:fs/promises";
|
|
import { platform } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
|
|
import { type } from "arktype";
|
|
|
|
import type { Headscale } from "~/server/headscale/api";
|
|
import log from "~/utils/log";
|
|
|
|
import { Integration } from "./abstract";
|
|
import { findHeadscaleServe, signalAndWaitHealthy } from "./proc-helper";
|
|
|
|
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
|
|
const svcRoot = "/var/run/secrets/kubernetes.io/serviceaccount";
|
|
const svcCaPath = `${svcRoot}/ca.crt`;
|
|
const svcTokenPath = `${svcRoot}/token`;
|
|
const svcNamespacePath = `${svcRoot}/namespace`;
|
|
|
|
const configSchema = {
|
|
full: type({
|
|
enabled: "boolean",
|
|
pod_name: "string",
|
|
validate_manifest: "boolean = true",
|
|
}),
|
|
|
|
partial: type({
|
|
enabled: "boolean?",
|
|
pod_name: "string?",
|
|
validate_manifest: "boolean?",
|
|
}).partial(),
|
|
};
|
|
|
|
export default class KubernetesIntegration extends Integration<typeof configSchema.full.infer> {
|
|
private pid: number | undefined;
|
|
|
|
get name() {
|
|
return "Kubernetes (k8s)";
|
|
}
|
|
|
|
static get configSchema() {
|
|
return configSchema;
|
|
}
|
|
|
|
async isAvailable() {
|
|
if (platform() !== "linux") {
|
|
log.error("config", "Kubernetes is only available on Linux");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
log.debug("config", "Checking Kubernetes service account at %s", svcRoot);
|
|
const files = await readdir(svcRoot);
|
|
if (files.length === 0) {
|
|
log.error("config", "Kubernetes service account not found");
|
|
return false;
|
|
}
|
|
|
|
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
|
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
|
|
|
|
log.debug("config", "Looking for %s", expectedFiles.join(", "));
|
|
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
|
log.error("config", "Malformed Kubernetes service account");
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
log.error("config", "Failed to access %s: %s", svcRoot, error);
|
|
return false;
|
|
}
|
|
|
|
log.debug("config", "Reading Kubernetes service account at %s", svcRoot);
|
|
const namespace = await readFile(svcNamespacePath, "utf8");
|
|
|
|
// Some very ugly nesting but it's necessary
|
|
if (this.context.validate_manifest === false) {
|
|
log.warn("config", "Skipping strict Pod status check");
|
|
} else {
|
|
const pod = this.context.pod_name;
|
|
if (!pod) {
|
|
log.error("config", "Missing POD_NAME variable");
|
|
return false;
|
|
}
|
|
|
|
if (pod.trim().length === 0) {
|
|
log.error("config", "Pod name is empty");
|
|
return false;
|
|
}
|
|
|
|
log.debug("config", "Checking Kubernetes pod %s in namespace %s", pod, namespace);
|
|
|
|
try {
|
|
log.debug("config", "Attempgin to get cluster KubeConfig");
|
|
const kc = new KubeConfig();
|
|
kc.loadFromCluster();
|
|
|
|
const cluster = kc.getCurrentCluster();
|
|
if (!cluster) {
|
|
log.error("config", "Malformed kubeconfig");
|
|
return false;
|
|
}
|
|
|
|
log.info("config", "Service account connected to %s (%s)", cluster.name, cluster.server);
|
|
|
|
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
|
|
|
log.info("config", "Checking pod %s in namespace %s", pod, namespace);
|
|
log.debug("config", "Reading pod info for %s", pod);
|
|
const body = await kCoreV1Api.readNamespacedPod({
|
|
name: pod,
|
|
namespace,
|
|
});
|
|
|
|
if (!body.spec) {
|
|
log.error("config", "Missing spec in pod info for %s/%s", pod, namespace);
|
|
|
|
return false;
|
|
}
|
|
|
|
log.debug("config", "Got pod info: %o", body.spec);
|
|
const shared = body.spec.shareProcessNamespace;
|
|
if (shared === undefined) {
|
|
log.error("config", "Pod does not have spec.shareProcessNamespace set");
|
|
|
|
return false;
|
|
}
|
|
|
|
if (!shared) {
|
|
log.error("config", "Pod has set but disabled spec.shareProcessNamespace");
|
|
|
|
return false;
|
|
}
|
|
|
|
log.info("config", "Pod %s enabled shared processes", pod);
|
|
} catch (error) {
|
|
log.error("config", "Failed to read pod info: %s", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await findHeadscaleServe();
|
|
if (!result) {
|
|
log.error("config", "Could not find headscale serve process");
|
|
return false;
|
|
}
|
|
|
|
this.pid = result;
|
|
log.info("config", "Found headscale serve (PID %d)", this.pid);
|
|
return true;
|
|
} catch (error) {
|
|
log.error("config", "Failed to scan /proc: %s", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async onConfigChange(headscale: Headscale) {
|
|
if (!this.pid) {
|
|
return;
|
|
}
|
|
|
|
await signalAndWaitHealthy(headscale, {
|
|
pid: this.pid,
|
|
signal: "SIGHUP",
|
|
});
|
|
}
|
|
}
|