9dbb5f337e
Adapter plugin that runs Claude Code agents as Kubernetes Jobs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
8.5 KiB
JavaScript
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
|