5926b302e5
The adapter was calling onSpawn({ pid: -1 }) as a sentinel value for
K8s Jobs (which run out-of-process), then the server's orphan reaper
was checking isProcessAlive(-1) which always returns false, causing
legitimate runs to be reaped as 'process_lost'.
Using process.pid (the Paperclip server's own PID) is always alive
while the adapter runs in-process, preventing false reaping.
Fixes FAR-116.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
136 lines
4.8 KiB
JavaScript
136 lines
4.8 KiB
JavaScript
import * as k8s from "@kubernetes/client-node";
|
|
import { readFileSync } from "node:fs";
|
|
let cachedSelfPod = null;
|
|
/**
|
|
* Cache keyed by kubeconfig path (empty string = in-cluster).
|
|
* Supports multiple agents with different kubeconfigs.
|
|
*/
|
|
const kcCache = new Map();
|
|
function getKubeConfig(kubeconfigPath) {
|
|
const key = kubeconfigPath ?? "";
|
|
let kc = kcCache.get(key);
|
|
if (!kc) {
|
|
kc = new k8s.KubeConfig();
|
|
if (kubeconfigPath) {
|
|
kc.loadFromFile(kubeconfigPath);
|
|
}
|
|
else {
|
|
kc.loadFromCluster();
|
|
}
|
|
kcCache.set(key, kc);
|
|
}
|
|
return kc;
|
|
}
|
|
export function getBatchApi(kubeconfigPath) {
|
|
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.BatchV1Api);
|
|
}
|
|
export function getCoreApi(kubeconfigPath) {
|
|
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.CoreV1Api);
|
|
}
|
|
export function getAuthzApi(kubeconfigPath) {
|
|
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.AuthorizationV1Api);
|
|
}
|
|
export function getLogApi(kubeconfigPath) {
|
|
return new k8s.Log(getKubeConfig(kubeconfigPath));
|
|
}
|
|
/**
|
|
* Read the current pod's namespace. Checks (in order):
|
|
* 1. PAPERCLIP_NAMESPACE env var (set explicitly in Deployment)
|
|
* 2. Service account namespace file (standard in-cluster path)
|
|
* 3. POD_NAMESPACE env var (Downward API convention)
|
|
* Falls back to "default" only if none of the above are available.
|
|
*/
|
|
function readInClusterNamespace() {
|
|
const fromEnv = process.env.PAPERCLIP_NAMESPACE ?? process.env.POD_NAMESPACE;
|
|
if (fromEnv?.trim())
|
|
return fromEnv.trim();
|
|
try {
|
|
return readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf-8").trim();
|
|
}
|
|
catch {
|
|
return "default";
|
|
}
|
|
}
|
|
/**
|
|
* Query the K8s API for our own pod spec and cache the result.
|
|
* Extracts image, imagePullSecrets, dnsConfig, PVC claim name,
|
|
* and environment variables to forward to Job pods.
|
|
*/
|
|
export async function getSelfPodInfo(kubeconfigPath) {
|
|
if (cachedSelfPod)
|
|
return cachedSelfPod;
|
|
const hostname = process.env.HOSTNAME;
|
|
if (!hostname) {
|
|
throw new Error("claude_k8s: HOSTNAME env var not set — cannot introspect running pod");
|
|
}
|
|
const namespace = readInClusterNamespace();
|
|
const coreApi = getCoreApi(kubeconfigPath);
|
|
const pod = await coreApi.readNamespacedPod({ name: hostname, namespace });
|
|
const spec = pod.spec;
|
|
if (!spec) {
|
|
throw new Error(`claude_k8s: pod ${hostname} has no spec`);
|
|
}
|
|
const mainContainer = spec.containers[0];
|
|
if (!mainContainer?.image) {
|
|
throw new Error(`claude_k8s: pod ${hostname} has no container image`);
|
|
}
|
|
// Find PVC claim name from volumes mounted at /paperclip
|
|
let pvcClaimName = null;
|
|
const dataMount = mainContainer.volumeMounts?.find((vm) => vm.mountPath === "/paperclip");
|
|
if (dataMount) {
|
|
const volume = spec.volumes?.find((v) => v.name === dataMount.name);
|
|
pvcClaimName = volume?.persistentVolumeClaim?.claimName ?? null;
|
|
}
|
|
// Discover secret volumes mounted on the main container
|
|
const secretVolumes = [];
|
|
for (const vm of mainContainer.volumeMounts ?? []) {
|
|
const vol = spec.volumes?.find((v) => v.name === vm.name);
|
|
if (vol?.secret?.secretName) {
|
|
secretVolumes.push({
|
|
volumeName: vm.name,
|
|
secretName: vol.secret.secretName,
|
|
mountPath: vm.mountPath,
|
|
defaultMode: vol.secret.defaultMode,
|
|
});
|
|
}
|
|
}
|
|
// Collect env vars from the pod spec's container definition.
|
|
// Agent config env (set in buildEnvVars) will override these.
|
|
const inheritedEnv = {};
|
|
const inheritedEnvValueFrom = [];
|
|
for (const envItem of mainContainer.env ?? []) {
|
|
if (!envItem.name)
|
|
continue;
|
|
if (envItem.valueFrom) {
|
|
// Preserve valueFrom entries (secretKeyRef, configMapKeyRef, fieldRef, etc.)
|
|
inheritedEnvValueFrom.push({ name: envItem.name, valueFrom: envItem.valueFrom });
|
|
}
|
|
else {
|
|
const value = envItem.value ?? "";
|
|
if (value)
|
|
inheritedEnv[envItem.name] = value;
|
|
}
|
|
}
|
|
// Capture envFrom sources (secretRef, configMapRef) from the container spec
|
|
const inheritedEnvFrom = mainContainer.envFrom ?? [];
|
|
cachedSelfPod = {
|
|
namespace,
|
|
image: mainContainer.image,
|
|
imagePullSecrets: (spec.imagePullSecrets ?? []).map((s) => ({
|
|
name: s.name ?? "",
|
|
})).filter((s) => s.name.length > 0),
|
|
dnsConfig: spec.dnsConfig,
|
|
pvcClaimName,
|
|
secretVolumes,
|
|
inheritedEnv,
|
|
inheritedEnvValueFrom,
|
|
inheritedEnvFrom,
|
|
};
|
|
return cachedSelfPod;
|
|
}
|
|
/** Reset cached state — useful for tests. */
|
|
export function resetCache() {
|
|
kcCache.clear();
|
|
cachedSelfPod = null;
|
|
}
|
|
//# sourceMappingURL=k8s-client.js.map
|