mirror of
https://github.com/tale/headplane.git
synced 2026-08-10 05:56:52 +00:00
0512565f8e
Apparently I didn't use my brain cells and rely on the /version endpoint that Headscale has exposed since 0.26 (our lowest supported version). Switching to that significantly simplifies the API surface.
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 { HeadscaleClient } 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(client: HeadscaleClient) {
|
|
if (!this.pid) {
|
|
return;
|
|
}
|
|
|
|
await signalAndWaitHealthy(client, {
|
|
pid: this.pid,
|
|
signal: "SIGHUP",
|
|
});
|
|
}
|
|
}
|