Files
paperclip-adapter-claude-k8s/dist/server/test.js
T
Chris Farhood 9dbb5f337e Initial commit: Paperclip adapter for Claude Code on Kubernetes
Adapter plugin that runs Claude Code agents as Kubernetes Jobs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:16:31 -04:00

210 lines
8.5 KiB
JavaScript

import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js";
function summarizeStatus(checks) {
if (checks.some((c) => c.level === "error"))
return "fail";
if (checks.some((c) => c.level === "warn"))
return "warn";
return "pass";
}
async function checkApiReachable(checks, kubeconfigPath) {
try {
const selfPod = await getSelfPodInfo(kubeconfigPath);
checks.push({
code: "k8s_api_reachable",
level: "info",
message: `Kubernetes API reachable; running in namespace ${selfPod.namespace}`,
detail: `Image: ${selfPod.image}`,
});
return true;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_api_unreachable",
level: "error",
message: `Cannot reach Kubernetes API: ${msg}`,
hint: "Ensure the pod has a valid service account token mounted and the API server is accessible.",
});
return false;
}
}
async function checkNamespace(namespace, selfPodNamespace, checks, kubeconfigPath) {
// If targeting the same namespace we're running in, skip the cluster-scoped
// readNamespace call — we know it exists, and the SA may lack cluster-level
// namespace get permissions.
if (namespace === selfPodNamespace) {
checks.push({
code: "k8s_namespace_exists",
level: "info",
message: `Target namespace is the pod namespace: ${namespace}`,
});
return true;
}
try {
const coreApi = getCoreApi(kubeconfigPath);
await coreApi.readNamespace({ name: namespace });
checks.push({
code: "k8s_namespace_exists",
level: "info",
message: `Target namespace exists: ${namespace}`,
});
return true;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_namespace_check_failed",
level: "warn",
message: `Cannot verify namespace "${namespace}": ${msg}`,
hint: "The service account may lack cluster-level namespace read permissions. The namespace may still be usable — verify RBAC checks below.",
});
// Don't block on this — RBAC checks below will catch actual permission issues
return true;
}
}
async function checkRbac(namespace, checks, kubeconfigPath) {
const authzApi = getAuthzApi(kubeconfigPath);
const rbacChecks = [
{ resource: "jobs", group: "batch", verb: "create", code: "k8s_rbac_job_create", label: "create Jobs" },
{ resource: "jobs", group: "batch", verb: "delete", code: "k8s_rbac_job_delete", label: "delete Jobs" },
{ resource: "jobs", group: "batch", verb: "get", code: "k8s_rbac_job_get", label: "get Jobs" },
{ resource: "pods", group: "", verb: "list", code: "k8s_rbac_pod_list", label: "list Pods" },
{ resource: "pods/log", group: "", verb: "get", code: "k8s_rbac_pod_log", label: "get Pod logs" },
];
for (const check of rbacChecks) {
try {
const review = await authzApi.createSelfSubjectAccessReview({
body: {
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: {
resourceAttributes: {
namespace,
verb: check.verb,
resource: check.resource,
group: check.group,
},
},
},
});
if (review.status?.allowed) {
checks.push({
code: check.code,
level: "info",
message: `RBAC: allowed to ${check.label} in ${namespace}`,
});
}
else {
checks.push({
code: check.code,
level: "error",
message: `RBAC: not allowed to ${check.label} in ${namespace}`,
hint: `Grant the service account permission to ${check.verb} ${check.resource} in namespace ${namespace}.`,
});
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: check.code,
level: "warn",
message: `RBAC check failed for ${check.label}: ${msg}`,
hint: "SelfSubjectAccessReview may not be available; verify permissions manually.",
});
}
}
}
async function checkSecret(namespace, secretName, checks, kubeconfigPath) {
try {
const coreApi = getCoreApi(kubeconfigPath);
await coreApi.readNamespacedSecret({ name: secretName, namespace });
checks.push({
code: "k8s_secret_exists",
level: "info",
message: `Secret "${secretName}" exists in namespace ${namespace}`,
});
}
catch {
checks.push({
code: "k8s_secret_missing",
level: "warn",
message: `Secret "${secretName}" not found in namespace ${namespace}`,
hint: `Ensure the paperclip-secrets Secret exists with keys for ANTHROPIC_API_KEY and/or AWS_BEARER_TOKEN_BEDROCK.`,
});
}
}
async function checkPvc(selfPod, checks, kubeconfigPath) {
if (!selfPod.pvcClaimName) {
checks.push({
code: "k8s_pvc_not_detected",
level: "warn",
message: "No PVC detected on /paperclip mount — session resume and workspace sharing will not work.",
hint: "Ensure the Paperclip Deployment has a PVC mounted at /paperclip with ReadWriteMany access mode.",
});
return;
}
try {
const coreApi = getCoreApi(kubeconfigPath);
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: selfPod.pvcClaimName,
namespace: selfPod.namespace,
});
const accessModes = pvc.spec?.accessModes ?? [];
const isRwx = accessModes.includes("ReadWriteMany");
if (isRwx) {
checks.push({
code: "k8s_pvc_rwx",
level: "info",
message: `PVC "${selfPod.pvcClaimName}" has ReadWriteMany access — Job pods can mount it.`,
});
}
else {
checks.push({
code: "k8s_pvc_not_rwx",
level: "warn",
message: `PVC "${selfPod.pvcClaimName}" access modes: ${accessModes.join(", ")}. ReadWriteMany is required for Job pods to share the volume.`,
hint: "Change the PVC accessMode to ReadWriteMany in Helm values.",
});
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_pvc_check_failed",
level: "warn",
message: `Could not read PVC "${selfPod.pvcClaimName}": ${msg}`,
});
}
}
export async function testEnvironment(ctx) {
const checks = [];
const config = parseObject(ctx.config);
const secretRef = asString(config.secretRef, "paperclip-secrets");
const kubeconfigPath = asString(config.kubeconfig, "") || undefined;
// 1. K8s API reachable + self-pod introspection
const apiOk = await checkApiReachable(checks, kubeconfigPath);
if (!apiOk) {
return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() };
}
const selfPod = await getSelfPodInfo(kubeconfigPath);
const namespace = asString(config.namespace, "") || selfPod.namespace;
// 2. Target namespace exists
const nsOk = await checkNamespace(namespace, selfPod.namespace, checks, kubeconfigPath);
if (!nsOk) {
return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() };
}
// 3-5. Run remaining checks in parallel
await Promise.all([
checkRbac(namespace, checks, kubeconfigPath),
checkSecret(namespace, secretRef, checks, kubeconfigPath),
checkPvc(selfPod, checks, kubeconfigPath),
]);
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
//# sourceMappingURL=test.js.map