feat(plugin-kubernetes): self-hostable Kubernetes sandbox provider (stage 1/3: plugin package) (#5790)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox providers are the seam that lets agent runs execute in isolated environments; today the only first-party remote provider is Daytona, a hosted third-party service > - Self-hosters running Paperclip on their own infrastructure (often Kubernetes already) have no first-party way to run agent sandboxes on a cluster they control > - That gap matters for teams with data-residency, sovereignty, or cost constraints who cannot or will not send workloads to a hosted sandbox service > - This pull request adds a Kubernetes sandbox-provider plugin as a standalone, workspace-excluded package: it implements every SandboxProvider hook the Daytona provider does, on infrastructure the operator owns > - The benefit is that any Paperclip deployment with a Kubernetes cluster gets multi-tenant, network-isolated, quota-bounded agent sandboxes with zero new external dependencies ## Linked Issues or Issue Description No existing issue. Following the feature template: - **Problem:** Paperclip's remote sandbox execution requires a hosted third-party provider. Self-hosters cannot run agent sandboxes on their own Kubernetes clusters with a first-party provider. - **Proposed solution:** A `@paperclipai/plugin-kubernetes` sandbox-provider plugin with two backends: long-lived sandboxes via the [kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) CRD (multi-command exec, adapter-install pattern) and one-shot `batch/v1` Jobs (stable APIs only, no extra controllers). - **Alternatives considered:** Driving kubectl from a generic shell provider (no lifecycle/lease semantics), or requiring a hosted provider (exactly the constraint this removes). ## What Changed This is **stage 1 of 3** of a staged contribution (direction agreed with maintainers): the plugin package alone. Stage 2 (server integration: lease params, provider registration) and stage 3 (agent runtime images + CI) are companion PRs that will be cross-linked from a comment here. - New package `packages/plugins/sandbox-providers/kubernetes` (workspace-excluded, like the path already carved out in `pnpm-workspace.yaml`): src, unit + kind integration tests, operator prerequisite manifests, README, smoke-test guide - Implements the full SandboxProvider hook surface the Daytona provider implements: `validateConfig`, `probe`, `acquireLease`, `resumeLease`, `releaseLease`, `destroyLease`, `realizeWorkspace`, `execute` - Two backends: `sandbox-cr` (default; long-lived pod via the agent-sandbox `Sandbox` CR, supports multi-command exec) and `job` (one-shot `batch/v1` Job; nothing beyond k8s 1.27+ required) - Per-run adapter resolution: one environment serves mixed harnesses; the per-run `adapterType` hint is read through a local optional type extension, so the plugin typechecks and builds against the current plugin SDK and simply falls back to the environment's configured default adapter until stage 2 lands - Exec-env wrapping: the Kubernetes exec API carries no environment, so commands are wrapped to receive the run's env - Fast-upload interception for workspace realization, scoped per lease - Per-tenant isolation: derived namespace per company, RBAC, ResourceQuota, restricted-PSS pod security (runAsNonRoot, drop ALL, seccomp RuntimeDefault, no SA token automount) - Network egress policy in two flavors: native `NetworkPolicy` and `CiliumNetworkPolicy` (FQDN allowlists) - Image allowlist with glob matching, registry override, and per-run image override validation - Per-run Kubernetes Secrets carrying agent credentials, ownerRef'd to the Job or Sandbox CR for cascade GC ## Verification - Standalone build, exactly as the README documents: ```bash cd packages/plugins/sandbox-providers/kubernetes pnpm install --ignore-workspace pnpm test # 147 unit tests, 17 files, all green pnpm typecheck # clean against the in-repo plugin SDK on master pnpm build # dist/ emitted, manifest + worker entrypoints present ``` - A kind-cluster end-to-end integration test is included (`RUN_K8S_INTEGRATION_TESTS=1 pnpm test test/integration/end-to-end-run.test.ts`) - Beyond CI: this provider has been verified in a production multi-tenant deployment against five harnesses (opencode, pi, codex, gemini, claude code) with real billed runs ## Risks - **Zero behavior change for any existing deployment.** The package is workspace-excluded; nothing in the server imports or loads it until stage 2's integration lands. No existing code paths are touched. - The default `sandbox-cr` backend depends on an alpha CRD (`agents.x-k8s.io/v1alpha1`); the README flags this and the `job` backend uses only stable APIs as a fallback. - Risk surface is confined to deployments that explicitly install and configure the plugin. - The default runtime images (`ghcr.io/paperclipai/agent-runtime-*`) are published by the stage 3 companion PR (#7934); until that lands, deployments must point `runtimeImage` at their own images. ## Model Used Claude Opus 4.8 (1M context), extended thinking, with tool use (Claude Code). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (no UI changes) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending this push) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
c139d6c025
commit
05ab45225a
@@ -0,0 +1,22 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const KIND_CONTEXT = "kind-paperclip";
|
||||
|
||||
export function readKindKubeconfig(): string {
|
||||
return readFileSync(join(homedir(), ".kube", "config"), "utf-8");
|
||||
}
|
||||
|
||||
export function kubectl(args: string): string {
|
||||
return execSync(`kubectl --context ${KIND_CONTEXT} ${args}`, { encoding: "utf-8" });
|
||||
}
|
||||
|
||||
export function deleteNamespaceIfExists(namespace: string): void {
|
||||
try {
|
||||
kubectl(`delete namespace ${namespace} --wait=true --timeout=60s --ignore-not-found`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* End-to-end integration test against a local kind cluster.
|
||||
*
|
||||
* PREREQUISITES (operator must perform before running this test):
|
||||
* 1. Create the kind cluster:
|
||||
* kind create cluster --name paperclip
|
||||
* 2. Pre-load the alpine image so the Job can start without network access:
|
||||
* docker pull alpine:3.20
|
||||
* docker tag alpine:3.20 localhost/paperclip-agent:latest
|
||||
* kind load docker-image localhost/paperclip-agent:latest --name paperclip
|
||||
* 3. For the sandbox-cr backend test, the agent-sandbox controller must be installed:
|
||||
* kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/install.yaml
|
||||
* And a tini-bearing image pre-loaded (e.g. the same localhost/paperclip-agent:latest
|
||||
* if it includes /usr/bin/tini and /bin/sh).
|
||||
* 4. Set the env var and run:
|
||||
* RUN_K8S_INTEGRATION_TESTS=1 pnpm test
|
||||
*
|
||||
* The namespace is derived from companySlug ("spike-e2e") + namespacePrefix
|
||||
* ("paperclip-"), resolving to "paperclip-spike-e2e".
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import plugin from "../../src/plugin.js";
|
||||
import { createKubeConfig } from "../../src/kube-client.js";
|
||||
import { execInPod } from "../../src/pod-exec.js";
|
||||
import { sandboxCrOrchestrator } from "../../src/sandbox-cr-orchestrator.js";
|
||||
import { deleteNamespaceIfExists, kubectl, readKindKubeconfig } from "./_kind-harness.js";
|
||||
|
||||
const NAMESPACE = "paperclip-spike-e2e";
|
||||
|
||||
describe("plugin-kubernetes end-to-end", () => {
|
||||
beforeAll(() => {
|
||||
if (process.env.RUN_K8S_INTEGRATION_TESTS !== "1") return;
|
||||
deleteNamespaceIfExists(NAMESPACE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (process.env.RUN_K8S_INTEGRATION_TESTS !== "1") return;
|
||||
deleteNamespaceIfExists(NAMESPACE);
|
||||
});
|
||||
|
||||
// ── Job backend (stable fallback) ─────────────────────────────────────────
|
||||
|
||||
it.runIf(process.env.RUN_K8S_INTEGRATION_TESTS === "1")(
|
||||
"[job backend] acquireLease creates tenant + Job + supporting resources; releaseLease cascade-deletes them",
|
||||
async () => {
|
||||
const kubeconfig = readKindKubeconfig();
|
||||
const config = {
|
||||
inCluster: false,
|
||||
kubeconfig,
|
||||
companySlug: "spike-e2e",
|
||||
adapterType: "claude_local",
|
||||
backend: "job",
|
||||
imageAllowList: [] as string[],
|
||||
podActivityDeadlineSec: 60,
|
||||
jobTtlSecondsAfterFinished: 60,
|
||||
};
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
runId: "r-test-e2e-job",
|
||||
companyId: "11111111-1111-1111-1111-111111111111",
|
||||
environmentId: "env-test",
|
||||
});
|
||||
|
||||
expect(lease.providerLeaseId).toMatch(/^pc-/);
|
||||
|
||||
// Verify the Job exists in the tenant namespace
|
||||
const jobs = kubectl(`get jobs -n ${NAMESPACE} -o name`);
|
||||
expect(jobs).toContain(`job.batch/${lease.providerLeaseId}`);
|
||||
|
||||
// Verify the tenant namespace has the expected supporting resources
|
||||
const all = kubectl(
|
||||
`get sa,role,rolebinding,resourcequota,limitrange,networkpolicy -n ${NAMESPACE} -o name`,
|
||||
);
|
||||
expect(all).toContain("serviceaccount/paperclip-tenant-sa");
|
||||
expect(all).toContain("role.rbac.authorization.k8s.io/paperclip-tenant-role");
|
||||
expect(all).toContain("rolebinding.rbac.authorization.k8s.io/paperclip-tenant-rb");
|
||||
expect(all).toContain("resourcequota/paperclip-quota");
|
||||
expect(all).toContain("limitrange/paperclip-limits");
|
||||
expect(all).toContain("networkpolicy.networking.k8s.io/paperclip-deny-all");
|
||||
expect(all).toContain("networkpolicy.networking.k8s.io/paperclip-egress-allow");
|
||||
|
||||
// Verify the namespace has PSS-restricted labels
|
||||
const ns = kubectl(`get namespace ${NAMESPACE} -o jsonpath='{.metadata.labels}'`);
|
||||
expect(ns).toContain("pod-security.kubernetes.io/enforce");
|
||||
expect(ns).toContain("restricted");
|
||||
|
||||
// Verify the per-run Secret exists (owned by the Job for cascade deletion)
|
||||
const secrets = kubectl(`get secrets -n ${NAMESPACE} -o name`);
|
||||
expect(secrets).toContain(`secret/${lease.providerLeaseId}-env`);
|
||||
|
||||
// Release — deletes the Job with Foreground propagation, which cascade-deletes
|
||||
// the owned Secret via owner references set at acquireLease time.
|
||||
await plugin.definition.onEnvironmentReleaseLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
leaseMetadata: lease.metadata,
|
||||
companyId: "11111111-1111-1111-1111-111111111111",
|
||||
environmentId: "env-test",
|
||||
});
|
||||
|
||||
// Allow a brief grace window for Foreground propagation to finish.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const jobsAfter = kubectl(`get jobs -n ${NAMESPACE} -o name 2>&1 || true`);
|
||||
expect(jobsAfter).not.toContain(`job.batch/${lease.providerLeaseId}`);
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
// ── Sandbox-CR backend (alpha, requires agent-sandbox controller) ──────────
|
||||
|
||||
it.runIf(process.env.RUN_K8S_INTEGRATION_TESTS === "1")(
|
||||
"[sandbox-cr backend] acquireLease creates Sandbox CR + supporting resources; pod becomes Ready; execInPod runs echo hello; releaseLease deletes CR",
|
||||
async () => {
|
||||
const kubeconfig = readKindKubeconfig();
|
||||
const config = {
|
||||
inCluster: false,
|
||||
kubeconfig,
|
||||
companySlug: "spike-e2e",
|
||||
adapterType: "claude_local",
|
||||
backend: "sandbox-cr",
|
||||
imageAllowList: [] as string[],
|
||||
podActivityDeadlineSec: 120,
|
||||
jobTtlSecondsAfterFinished: 60,
|
||||
};
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
runId: "r-test-e2e-sandbox-cr",
|
||||
companyId: "22222222-2222-2222-2222-222222222222",
|
||||
environmentId: "env-test-cr",
|
||||
});
|
||||
|
||||
expect(lease.providerLeaseId).toMatch(/^pc-/);
|
||||
|
||||
// Verify the Sandbox CR exists in the tenant namespace
|
||||
const sandboxes = kubectl(
|
||||
`get sandboxes.agents.x-k8s.io -n ${NAMESPACE} -o name 2>&1`,
|
||||
);
|
||||
expect(sandboxes).toContain(`sandbox.agents.x-k8s.io/${lease.providerLeaseId}`);
|
||||
|
||||
// Verify the per-run Secret exists (owned by the Sandbox CR)
|
||||
const secrets = kubectl(`get secrets -n ${NAMESPACE} -o name`);
|
||||
expect(secrets).toContain(`secret/${lease.providerLeaseId}-env`);
|
||||
|
||||
// Wait for the Sandbox pod to become Ready
|
||||
const kc = createKubeConfig({ inCluster: false, kubeconfig });
|
||||
const { makeKubeClients } = await import("../../src/kube-client.js");
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
await sandboxCrOrchestrator.waitForCompletion(
|
||||
clients,
|
||||
NAMESPACE,
|
||||
lease.providerLeaseId,
|
||||
{ timeoutMs: 90_000, pollMs: 3000 },
|
||||
);
|
||||
|
||||
// Resolve the pod name
|
||||
const podName = await sandboxCrOrchestrator.findPod(
|
||||
clients,
|
||||
NAMESPACE,
|
||||
lease.providerLeaseId,
|
||||
);
|
||||
expect(podName).toBeTruthy();
|
||||
|
||||
// Exec a simple echo command into the running pod
|
||||
const execResult = await execInPod(
|
||||
kc,
|
||||
NAMESPACE,
|
||||
podName!,
|
||||
"agent",
|
||||
["echo", "hello"],
|
||||
);
|
||||
|
||||
expect(execResult.exitCode).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe("hello");
|
||||
|
||||
// Release — deletes the Sandbox CR with Foreground propagation.
|
||||
await plugin.definition.onEnvironmentReleaseLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
leaseMetadata: lease.metadata,
|
||||
companyId: "22222222-2222-2222-2222-222222222222",
|
||||
environmentId: "env-test-cr",
|
||||
});
|
||||
|
||||
// Allow a brief grace window for Foreground propagation.
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const sandboxesAfter = kubectl(
|
||||
`get sandboxes.agents.x-k8s.io -n ${NAMESPACE} -o name 2>&1 || true`,
|
||||
);
|
||||
expect(sandboxesAfter).not.toContain(
|
||||
`sandbox.agents.x-k8s.io/${lease.providerLeaseId}`,
|
||||
);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getAdapterDefaults,
|
||||
buildAdapterEnv,
|
||||
resolveRunAdapterType,
|
||||
KNOWN_ADAPTER_TYPES,
|
||||
type AdapterDefaults,
|
||||
} from "../../src/adapter-defaults.js";
|
||||
import type { AdapterRegistryEntry } from "../../src/adapter-registry.js";
|
||||
|
||||
describe("adapter-defaults (built-in)", () => {
|
||||
it("returns defaults for claude_local", () => {
|
||||
const d = getAdapterDefaults("claude_local");
|
||||
expect(d.runtimeImage).toBe("ghcr.io/paperclipai/agent-runtime-claude:v1");
|
||||
expect(d.envKeys).toContain("ANTHROPIC_API_KEY");
|
||||
expect(d.allowFqdns).toContain("api.anthropic.com");
|
||||
expect(d.probeCommand).toEqual(["claude", "--version"]);
|
||||
});
|
||||
|
||||
it("returns defaults for codex_local", () => {
|
||||
const d = getAdapterDefaults("codex_local");
|
||||
expect(d.runtimeImage).toBe("ghcr.io/paperclipai/agent-runtime-codex:v1");
|
||||
expect(d.envKeys).toContain("OPENAI_API_KEY");
|
||||
expect(d.probeCommand).toEqual(["codex", "--version"]);
|
||||
});
|
||||
|
||||
it("throws on unknown adapter type", () => {
|
||||
expect(() => getAdapterDefaults("nonexistent_local")).toThrow(/unknown adapter type/i);
|
||||
});
|
||||
|
||||
it("KNOWN_ADAPTER_TYPES contains all 7 supported adapters", () => {
|
||||
expect(KNOWN_ADAPTER_TYPES).toEqual(
|
||||
new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"cursor_local",
|
||||
"opencode_local",
|
||||
"acpx_local",
|
||||
"pi_local",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdapterDefaults", () => {
|
||||
it("returns built-in defaults when no registry is supplied", () => {
|
||||
const d = getAdapterDefaults("claude_local");
|
||||
expect(d.runtimeImage).toContain("agent-runtime-claude");
|
||||
expect(d.envKeys).toEqual(["ANTHROPIC_API_KEY"]);
|
||||
expect(d.defaultEnv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws on an unknown built-in type when no registry is supplied", () => {
|
||||
expect(() => getAdapterDefaults("nope")).toThrow(/Unknown adapter type/);
|
||||
});
|
||||
|
||||
it("resolves from the supplied registry (replace semantics, not merge)", () => {
|
||||
const registry: AdapterRegistryEntry[] = [
|
||||
{
|
||||
adapterType: "opencode_local",
|
||||
enabled: true,
|
||||
runtimeImage: "registry.example/opencode:eu",
|
||||
envKeys: ["ANTHROPIC_API_KEY"],
|
||||
allowFqdns: [],
|
||||
probeCommand: ["opencode", "--version"],
|
||||
defaultEnv: { ANTHROPIC_BASE_URL: "http://bifrost:8080" },
|
||||
},
|
||||
];
|
||||
const d = getAdapterDefaults("opencode_local", registry);
|
||||
expect(d.runtimeImage).toBe("registry.example/opencode:eu");
|
||||
expect(d.defaultEnv).toEqual({ ANTHROPIC_BASE_URL: "http://bifrost:8080" });
|
||||
});
|
||||
|
||||
it("throws when the type is absent from a supplied registry", () => {
|
||||
const registry: AdapterRegistryEntry[] = [
|
||||
{
|
||||
adapterType: "opencode_local",
|
||||
runtimeImage: "x",
|
||||
envKeys: [],
|
||||
allowFqdns: [],
|
||||
probeCommand: ["x"],
|
||||
},
|
||||
];
|
||||
expect(() => getAdapterDefaults("claude_local", registry)).toThrow(
|
||||
/not in the configured adapter registry/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a supplied registry entry is missing runtimeImage", () => {
|
||||
const registry: AdapterRegistryEntry[] = [
|
||||
{ adapterType: "opencode_local", envKeys: [], allowFqdns: [], probeCommand: ["x"] },
|
||||
];
|
||||
expect(() => getAdapterDefaults("opencode_local", registry)).toThrow(
|
||||
/missing required runtime field: runtimeImage/,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the optional array fields to [] when the registry omits them", () => {
|
||||
const registry: AdapterRegistryEntry[] = [
|
||||
{ adapterType: "opencode_local", runtimeImage: "img" },
|
||||
];
|
||||
const d = getAdapterDefaults("opencode_local", registry);
|
||||
expect(d.envKeys).toEqual([]);
|
||||
expect(d.allowFqdns).toEqual([]);
|
||||
expect(d.probeCommand).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAdapterEnv", () => {
|
||||
it("layers process-env (secret) over defaultEnv (non-secret base)", () => {
|
||||
const defaults: AdapterDefaults = {
|
||||
runtimeImage: "x",
|
||||
envKeys: ["ANTHROPIC_API_KEY"],
|
||||
allowFqdns: [],
|
||||
probeCommand: ["x"],
|
||||
defaultEnv: { ANTHROPIC_BASE_URL: "http://bifrost:8080", ANTHROPIC_API_KEY: "should-be-overridden" },
|
||||
};
|
||||
const env = buildAdapterEnv(defaults, { ANTHROPIC_API_KEY: "sk-real" });
|
||||
expect(env).toEqual({
|
||||
ANTHROPIC_BASE_URL: "http://bifrost:8080",
|
||||
ANTHROPIC_API_KEY: "sk-real",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits process-env keys that are absent", () => {
|
||||
const defaults: AdapterDefaults = {
|
||||
runtimeImage: "x",
|
||||
envKeys: ["ANTHROPIC_API_KEY"],
|
||||
allowFqdns: [],
|
||||
probeCommand: ["x"],
|
||||
};
|
||||
expect(buildAdapterEnv(defaults, {})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRunAdapterType", () => {
|
||||
it("prefers the run/agent adapter when provided (mixed-harness env)", () => {
|
||||
expect(resolveRunAdapterType("pi_local", "opencode_local")).toBe("pi_local");
|
||||
});
|
||||
it("falls back to the environment default when the run adapter is missing/blank", () => {
|
||||
expect(resolveRunAdapterType(undefined, "opencode_local")).toBe("opencode_local");
|
||||
expect(resolveRunAdapterType(null, "opencode_local")).toBe("opencode_local");
|
||||
expect(resolveRunAdapterType(" ", "opencode_local")).toBe("opencode_local");
|
||||
});
|
||||
it("trims the run adapter", () => {
|
||||
expect(resolveRunAdapterType(" pi_local ", "opencode_local")).toBe("pi_local");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildCiliumNetworkPolicyManifest } from "../../src/cilium-network-policy.js";
|
||||
|
||||
describe("buildCiliumNetworkPolicyManifest", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
egressAllowFqdns: ["api.anthropic.com"],
|
||||
egressAllowCidrs: [] as string[],
|
||||
};
|
||||
|
||||
it("returns a CiliumNetworkPolicy with the correct apiVersion and kind", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
expect(cnp.apiVersion).toBe("cilium.io/v2");
|
||||
expect(cnp.kind).toBe("CiliumNetworkPolicy");
|
||||
});
|
||||
|
||||
it("targets agent pods by role label", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
expect(cnp.spec.endpointSelector.matchLabels["paperclip.io/role"]).toBe("agent");
|
||||
});
|
||||
|
||||
it("includes an FQDN allow rule for each adapter FQDN", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest({
|
||||
...baseInput,
|
||||
egressAllowFqdns: ["api.anthropic.com", "api.openai.com"],
|
||||
});
|
||||
const fqdnRule = cnp.spec.egress.find((e: { toFQDNs?: { matchName: string }[] }) => e.toFQDNs);
|
||||
expect(fqdnRule).toBeDefined();
|
||||
expect(fqdnRule.toFQDNs.map((f: { matchName: string }) => f.matchName).sort()).toEqual([
|
||||
"api.anthropic.com",
|
||||
"api.openai.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("permits DNS to kube-dns explicitly so FQDN resolution can happen", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
const dnsRule = cnp.spec.egress.find((e: { toPorts?: { ports: { port: string }[] }[] }) =>
|
||||
e.toPorts?.some((tp) => tp.ports.some((p) => p.port === "53")),
|
||||
);
|
||||
expect(dnsRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes a rule for paperclip-server callback", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
const cb = cnp.spec.egress.find((e: { toEndpoints?: { matchLabels: Record<string, string> }[] }) =>
|
||||
e.toEndpoints?.some((ep) => ep.matchLabels.app === "paperclip-server"),
|
||||
);
|
||||
expect(cb).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes user-supplied CIDRs in toCIDRSet rule", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest({
|
||||
...baseInput,
|
||||
egressAllowCidrs: ["10.0.0.0/8"],
|
||||
});
|
||||
const cidrRule = cnp.spec.egress.find((e: { toCIDRSet?: { cidr: string }[] }) => e.toCIDRSet);
|
||||
expect(cidrRule.toCIDRSet[0].cidr).toBe("10.0.0.0/8");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { globMatch, resolveImage } from "../../src/image-allowlist.js";
|
||||
|
||||
describe("globMatch", () => {
|
||||
it("matches exact image", () => {
|
||||
expect(globMatch("ghcr.io/paperclipai/agent-runtime-claude:v1", "ghcr.io/paperclipai/agent-runtime-claude:v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches single-character wildcard", () => {
|
||||
expect(globMatch("ghcr.io/x:v?", "ghcr.io/x:v1")).toBe(true);
|
||||
expect(globMatch("ghcr.io/x:v?", "ghcr.io/x:v12")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches multi-character wildcard", () => {
|
||||
expect(globMatch("ghcr.io/paperclipai/*:v1", "ghcr.io/paperclipai/agent-runtime-claude:v1")).toBe(true);
|
||||
expect(globMatch("ghcr.io/paperclipai/*:v1", "docker.io/other/img:v1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not allow wildcard to span slashes by default", () => {
|
||||
expect(globMatch("ghcr.io/*:v1", "ghcr.io/paperclipai/agent-runtime-claude:v1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveImage", () => {
|
||||
const defaults = { runtimeImage: "ghcr.io/paperclipai/agent-runtime-claude:v1" };
|
||||
|
||||
it("uses adapter default when no override", () => {
|
||||
expect(resolveImage({ imageOverride: null }, defaults, { imageAllowList: [], imageRegistry: undefined })).toBe(
|
||||
"ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites registry when imageRegistry is set", () => {
|
||||
expect(
|
||||
resolveImage(
|
||||
{ imageOverride: null },
|
||||
defaults,
|
||||
{ imageAllowList: [], imageRegistry: "registry.example.com/paperclip" },
|
||||
),
|
||||
).toBe("registry.example.com/paperclip/agent-runtime-claude:v1");
|
||||
});
|
||||
|
||||
it("accepts imageOverride when in allowlist", () => {
|
||||
expect(
|
||||
resolveImage(
|
||||
{ imageOverride: "registry.example.com/mine:v2" },
|
||||
defaults,
|
||||
{ imageAllowList: ["registry.example.com/*:v2"], imageRegistry: undefined },
|
||||
),
|
||||
).toBe("registry.example.com/mine:v2");
|
||||
});
|
||||
|
||||
it("rejects imageOverride not in allowlist", () => {
|
||||
expect(() =>
|
||||
resolveImage(
|
||||
{ imageOverride: "evil.io/img:latest" },
|
||||
defaults,
|
||||
{ imageAllowList: ["registry.example.com/*"], imageRegistry: undefined },
|
||||
),
|
||||
).toThrow(/not in allowlist/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createJob, deleteJob, getJobStatus, findPodForJob, JobTimeoutError, waitForJobCompletion } from "../../src/job-orchestrator.js";
|
||||
|
||||
describe("createJob", () => {
|
||||
it("calls batch.createNamespacedJob with the manifest", async () => {
|
||||
const create = vi.fn().mockResolvedValue({ metadata: { uid: "abc-uid" } });
|
||||
const clients = { batch: { createNamespacedJob: create } };
|
||||
const jobManifest = { apiVersion: "batch/v1", kind: "Job", metadata: { name: "r-1", namespace: "ns" }, spec: { template: {} } };
|
||||
const result = await createJob(clients as never, "ns", jobManifest);
|
||||
expect(create).toHaveBeenCalledWith({ namespace: "ns", body: jobManifest });
|
||||
expect(result.uid).toBe("abc-uid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getJobStatus", () => {
|
||||
it("returns phase=Succeeded when succeeded count is 1", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { succeeded: 1, conditions: [{ type: "Complete", status: "True" }] } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Succeeded");
|
||||
expect(status.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("returns phase=Failed when failed count is >0", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { failed: 1, conditions: [{ type: "Failed", status: "True", reason: "DeadlineExceeded" }] } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Failed");
|
||||
expect(status.reason).toBe("DeadlineExceeded");
|
||||
});
|
||||
|
||||
it("returns phase=Running when active count is >0", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { active: 1 } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Running");
|
||||
});
|
||||
|
||||
it("returns phase=Pending when no active/succeeded/failed counters set", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: {} });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPodForJob", () => {
|
||||
it("lists pods by job-name label and returns the first running pod", async () => {
|
||||
const list = vi.fn().mockResolvedValue({ items: [{ metadata: { name: "r-1-xyz" }, status: { phase: "Running" } }] });
|
||||
const clients = { core: { listNamespacedPod: list } };
|
||||
const podName = await findPodForJob(clients as never, "ns", "r-1");
|
||||
expect(list).toHaveBeenCalledWith(expect.objectContaining({ namespace: "ns", labelSelector: "job-name=r-1" }));
|
||||
expect(podName).toBe("r-1-xyz");
|
||||
});
|
||||
|
||||
it("returns null when no pod is found", async () => {
|
||||
const list = vi.fn().mockResolvedValue({ items: [] });
|
||||
const clients = { core: { listNamespacedPod: list } };
|
||||
const podName = await findPodForJob(clients as never, "ns", "r-1");
|
||||
expect(podName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteJob", () => {
|
||||
it("calls batch.deleteNamespacedJob with foreground propagation", async () => {
|
||||
const del = vi.fn().mockResolvedValue({});
|
||||
const clients = { batch: { deleteNamespacedJob: del } };
|
||||
await deleteJob(clients as never, "ns", "r-1");
|
||||
expect(del).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
namespace: "ns",
|
||||
name: "r-1",
|
||||
propagationPolicy: "Foreground",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForJobCompletion", () => {
|
||||
it("throws JobTimeoutError when the deadline is exceeded", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { active: 1 } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
await expect(
|
||||
waitForJobCompletion(clients as never, "ns", "r-1", { timeoutMs: 50, pollMs: 10 }),
|
||||
).rejects.toBeInstanceOf(JobTimeoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { createKubeConfig } from "../../src/kube-client.js";
|
||||
|
||||
describe("createKubeConfig", () => {
|
||||
it("loads from inline kubeconfig string", () => {
|
||||
const yaml = `apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: test
|
||||
cluster:
|
||||
server: https://fake.example.com
|
||||
contexts:
|
||||
- name: test
|
||||
context:
|
||||
cluster: test
|
||||
user: test
|
||||
current-context: test
|
||||
users:
|
||||
- name: test
|
||||
user:
|
||||
token: fake-token
|
||||
`;
|
||||
const kc = createKubeConfig({ inCluster: false, kubeconfig: yaml });
|
||||
expect(kc.getCurrentContext()).toBe("test");
|
||||
expect(kc.getCurrentCluster()?.server).toBe("https://fake.example.com");
|
||||
});
|
||||
|
||||
it("loads from-cluster config when inCluster=true", () => {
|
||||
const spy = vi.spyOn(KubeConfig.prototype, "loadFromCluster").mockImplementation(function (this: KubeConfig) {
|
||||
this.loadFromString(`apiVersion: v1
|
||||
kind: Config
|
||||
clusters: [{name: in-cluster, cluster: {server: 'https://kubernetes.default.svc'}}]
|
||||
contexts: [{name: in-cluster, context: {cluster: in-cluster, user: in-cluster}}]
|
||||
current-context: in-cluster
|
||||
users: [{name: in-cluster, user: {token: tok}}]`);
|
||||
});
|
||||
const kc = createKubeConfig({ inCluster: true });
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect(kc.getCurrentContext()).toBe("in-cluster");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("throws when neither inCluster nor kubeconfig string is provided", () => {
|
||||
expect(() => createKubeConfig({ inCluster: false })).toThrow(/requires/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
checkLeaseResumable,
|
||||
destroyLeaseResources,
|
||||
isKubeNotFoundError,
|
||||
} from "../../src/lease-lifecycle.js";
|
||||
|
||||
const SANDBOX_GROUP = "agents.x-k8s.io";
|
||||
const SANDBOX_VERSION = "v1alpha1";
|
||||
const SANDBOX_PLURAL = "sandboxes";
|
||||
|
||||
function notFound(): Error {
|
||||
return Object.assign(new Error("not found"), { code: 404 });
|
||||
}
|
||||
|
||||
function readySandboxCr(podName?: string): Record<string, unknown> {
|
||||
return {
|
||||
metadata: { uid: "uid-1" },
|
||||
status: {
|
||||
conditions: [{ type: "Ready", status: "True" }],
|
||||
...(podName ? { podName } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("isKubeNotFoundError", () => {
|
||||
it("matches code=404 and statusCode=404", () => {
|
||||
expect(isKubeNotFoundError({ code: 404 })).toBe(true);
|
||||
expect(isKubeNotFoundError({ statusCode: 404 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match other errors", () => {
|
||||
expect(isKubeNotFoundError({ code: 500 })).toBe(false);
|
||||
expect(isKubeNotFoundError(new Error("boom"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLeaseResumable (sandbox-cr backend)", () => {
|
||||
it("resumes a live lease whose Sandbox is Ready and pod is Running", async () => {
|
||||
const clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")),
|
||||
},
|
||||
core: {
|
||||
readNamespacedPod: vi.fn().mockResolvedValue({
|
||||
metadata: {},
|
||||
status: { phase: "Running" },
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
readyTimeoutMs: 1_000,
|
||||
pollMs: 10,
|
||||
});
|
||||
expect(result).toEqual({ resumable: true, podName: "pc-abc-pod", phase: "Running" });
|
||||
expect(clients.core.readNamespacedPod).toHaveBeenCalledWith({
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc-pod",
|
||||
});
|
||||
});
|
||||
|
||||
it("is not resumable when the Sandbox CR is gone (404)", async () => {
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
|
||||
core: { readNamespacedPod: vi.fn() },
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
readyTimeoutMs: 1_000,
|
||||
pollMs: 10,
|
||||
});
|
||||
expect(result.resumable).toBe(false);
|
||||
if (!result.resumable) expect(result.reason).toMatch(/no longer exists/);
|
||||
});
|
||||
|
||||
it("is not resumable when the Sandbox is terminally Failed", async () => {
|
||||
const clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue({
|
||||
metadata: { uid: "uid-1" },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
conditions: [
|
||||
{ type: "Failed", status: "True", reason: "ImagePullFailed", message: "no image" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
core: { readNamespacedPod: vi.fn() },
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
readyTimeoutMs: 1_000,
|
||||
pollMs: 10,
|
||||
});
|
||||
expect(result.resumable).toBe(false);
|
||||
if (!result.resumable) expect(result.reason).toMatch(/failed/i);
|
||||
});
|
||||
|
||||
it("is not resumable when the Sandbox never reaches Ready within the bounded wait", async () => {
|
||||
const clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue({
|
||||
metadata: { uid: "uid-1" },
|
||||
status: { phase: "Pending" },
|
||||
}),
|
||||
},
|
||||
core: { readNamespacedPod: vi.fn() },
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
readyTimeoutMs: 30,
|
||||
pollMs: 5,
|
||||
});
|
||||
expect(result.resumable).toBe(false);
|
||||
if (!result.resumable) expect(result.reason).toMatch(/did not reach Ready/);
|
||||
});
|
||||
|
||||
it("is not resumable when the backing pod is gone (404)", async () => {
|
||||
const clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")),
|
||||
},
|
||||
core: { readNamespacedPod: vi.fn().mockRejectedValue(notFound()) },
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
readyTimeoutMs: 1_000,
|
||||
pollMs: 10,
|
||||
});
|
||||
expect(result.resumable).toBe(false);
|
||||
if (!result.resumable) expect(result.reason).toMatch(/pc-abc-pod no longer exists/);
|
||||
});
|
||||
|
||||
it("is not resumable when the pod is being torn down (deletionTimestamp set)", async () => {
|
||||
const clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")),
|
||||
},
|
||||
core: {
|
||||
readNamespacedPod: vi.fn().mockResolvedValue({
|
||||
metadata: { deletionTimestamp: "2026-06-10T00:00:00Z" },
|
||||
status: { phase: "Running" },
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
readyTimeoutMs: 1_000,
|
||||
pollMs: 10,
|
||||
});
|
||||
expect(result.resumable).toBe(false);
|
||||
if (!result.resumable) expect(result.reason).toMatch(/terminating/);
|
||||
});
|
||||
|
||||
it("rethrows unexpected (non-404) pod read errors", async () => {
|
||||
const clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")),
|
||||
},
|
||||
core: {
|
||||
readNamespacedPod: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error("forbidden"), { code: 403 })),
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
readyTimeoutMs: 1_000,
|
||||
pollMs: 10,
|
||||
}),
|
||||
).rejects.toThrow("forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLeaseResumable (job backend)", () => {
|
||||
it("resumes a Running Job lease", async () => {
|
||||
const clients = {
|
||||
batch: {
|
||||
readNamespacedJobStatus: vi.fn().mockResolvedValue({ status: { active: 1 } }),
|
||||
},
|
||||
core: {
|
||||
listNamespacedPod: vi.fn().mockResolvedValue({
|
||||
items: [{ metadata: { name: "pc-job-pod" }, status: { phase: "Running" } }],
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-job",
|
||||
backend: "job",
|
||||
});
|
||||
expect(result).toEqual({ resumable: true, podName: "pc-job-pod", phase: "Running" });
|
||||
});
|
||||
|
||||
it("is not resumable when the Job is gone (404)", async () => {
|
||||
const clients = {
|
||||
batch: { readNamespacedJobStatus: vi.fn().mockRejectedValue(notFound()) },
|
||||
core: { listNamespacedPod: vi.fn() },
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-job",
|
||||
backend: "job",
|
||||
});
|
||||
expect(result.resumable).toBe(false);
|
||||
if (!result.resumable) expect(result.reason).toMatch(/no longer exists/);
|
||||
});
|
||||
|
||||
it("is not resumable when the Job already finished (terminal phase)", async () => {
|
||||
const clients = {
|
||||
batch: {
|
||||
readNamespacedJobStatus: vi.fn().mockResolvedValue({
|
||||
status: { succeeded: 1, conditions: [{ type: "Complete", status: "True" }] },
|
||||
}),
|
||||
},
|
||||
core: { listNamespacedPod: vi.fn() },
|
||||
};
|
||||
const result = await checkLeaseResumable(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-job",
|
||||
backend: "job",
|
||||
});
|
||||
expect(result.resumable).toBe(false);
|
||||
if (!result.resumable) expect(result.reason).toMatch(/Succeeded/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroyLeaseResources", () => {
|
||||
function makeClients() {
|
||||
return {
|
||||
custom: { deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}) },
|
||||
batch: { deleteNamespacedJob: vi.fn().mockResolvedValue({}) },
|
||||
core: {
|
||||
deleteNamespacedPod: vi.fn().mockResolvedValue({}),
|
||||
deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("deletes the Sandbox CR, pod, and per-run Secret (sandbox-cr backend)", async () => {
|
||||
const clients = makeClients();
|
||||
await destroyLeaseResources(clients as never, {
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
podName: "pc-abc-pod",
|
||||
secretName: "pc-abc-env",
|
||||
});
|
||||
expect(clients.custom.deleteNamespacedCustomObject).toHaveBeenCalledWith({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace: "paperclip-acme",
|
||||
plural: SANDBOX_PLURAL,
|
||||
name: "pc-abc",
|
||||
propagationPolicy: "Foreground",
|
||||
});
|
||||
expect(clients.core.deleteNamespacedPod).toHaveBeenCalledWith({
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc-pod",
|
||||
});
|
||||
expect(clients.core.deleteNamespacedSecret).toHaveBeenCalledWith({
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc-env",
|
||||
});
|
||||
expect(clients.batch.deleteNamespacedJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the Job instead of the Sandbox CR (job backend)", async () => {
|
||||
const clients = makeClients();
|
||||
await destroyLeaseResources(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-job",
|
||||
backend: "job",
|
||||
podName: null,
|
||||
secretName: "pc-job-env",
|
||||
});
|
||||
expect(clients.batch.deleteNamespacedJob).toHaveBeenCalledWith({
|
||||
namespace: "ns",
|
||||
name: "pc-job",
|
||||
propagationPolicy: "Foreground",
|
||||
});
|
||||
expect(clients.custom.deleteNamespacedCustomObject).not.toHaveBeenCalled();
|
||||
expect(clients.core.deleteNamespacedPod).not.toHaveBeenCalled();
|
||||
expect(clients.core.deleteNamespacedSecret).toHaveBeenCalledWith({
|
||||
namespace: "ns",
|
||||
name: "pc-job-env",
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent: every 404 is treated as success", async () => {
|
||||
const clients = {
|
||||
custom: { deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
|
||||
batch: { deleteNamespacedJob: vi.fn() },
|
||||
core: {
|
||||
deleteNamespacedPod: vi.fn().mockRejectedValue(notFound()),
|
||||
deleteNamespacedSecret: vi.fn().mockRejectedValue(notFound()),
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
destroyLeaseResources(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
podName: "pc-abc-pod",
|
||||
secretName: "pc-abc-env",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(clients.core.deleteNamespacedSecret).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rethrows unexpected (non-404) delete errors", async () => {
|
||||
const clients = {
|
||||
custom: {
|
||||
deleteNamespacedCustomObject: vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error("forbidden"), { code: 403 })),
|
||||
},
|
||||
batch: { deleteNamespacedJob: vi.fn() },
|
||||
core: { deleteNamespacedPod: vi.fn(), deleteNamespacedSecret: vi.fn() },
|
||||
};
|
||||
await expect(
|
||||
destroyLeaseResources(clients as never, {
|
||||
namespace: "ns",
|
||||
name: "pc-abc",
|
||||
backend: "sandbox-cr",
|
||||
podName: null,
|
||||
secretName: null,
|
||||
}),
|
||||
).rejects.toThrow("forbidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildNetworkPolicyManifests } from "../../src/network-policy.js";
|
||||
|
||||
describe("buildNetworkPolicyManifests", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
egressAllowCidrs: [] as string[],
|
||||
};
|
||||
|
||||
it("produces a deny-all + egress allow pair", () => {
|
||||
const manifests = buildNetworkPolicyManifests(baseInput);
|
||||
expect(manifests).toHaveLength(2);
|
||||
expect(manifests[0].metadata.name).toBe("paperclip-deny-all");
|
||||
expect(manifests[1].metadata.name).toBe("paperclip-egress-allow");
|
||||
});
|
||||
|
||||
it("deny-all has no ingress/egress rules and applies to all pods", () => {
|
||||
const [denyAll] = buildNetworkPolicyManifests(baseInput);
|
||||
expect(denyAll.spec.podSelector).toEqual({});
|
||||
expect(denyAll.spec.policyTypes).toEqual(["Ingress", "Egress"]);
|
||||
expect(denyAll.spec.ingress).toBeUndefined();
|
||||
expect(denyAll.spec.egress).toBeUndefined();
|
||||
});
|
||||
|
||||
it("egress allow includes kube-dns and paperclip-server callback", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests(baseInput);
|
||||
const rules = egress.spec.egress;
|
||||
const dnsRule = rules.find((r: { ports?: { protocol: string; port: number }[] }) =>
|
||||
r.ports?.some((p) => p.port === 53),
|
||||
);
|
||||
expect(dnsRule).toBeDefined();
|
||||
const paperclipRule = rules.find((r: { to: { namespaceSelector?: { matchLabels?: Record<string, string> } }[] }) =>
|
||||
r.to.some((t) => t.namespaceSelector?.matchLabels?.["kubernetes.io/metadata.name"] === "paperclip"),
|
||||
);
|
||||
expect(paperclipRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes user-supplied CIDRs in egress allow", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests({ ...baseInput, egressAllowCidrs: ["10.0.0.0/8"] });
|
||||
const cidrRule = egress.spec.egress.find((r: { to: { ipBlock?: { cidr: string } }[] }) =>
|
||||
r.to.some((t) => t.ipBlock?.cidr === "10.0.0.0/8"),
|
||||
);
|
||||
expect(cidrRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses paperclip-server pod label selector for callback ingress to paperclip ns", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests(baseInput);
|
||||
const callbackRule = egress.spec.egress.find((r: { to: { podSelector?: { matchLabels?: Record<string, string> } }[] }) =>
|
||||
r.to.some((t) => t.podSelector?.matchLabels?.app === "paperclip-server"),
|
||||
);
|
||||
expect(callbackRule).toBeDefined();
|
||||
expect(callbackRule.ports[0].port).toBe(3100);
|
||||
});
|
||||
|
||||
it("adds public-IPv4 fallback (with private/link-local excluded) when FQDNs are configured and no CIDRs are supplied", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests({
|
||||
...baseInput,
|
||||
egressAllowFqdns: ["api.anthropic.com"],
|
||||
});
|
||||
const fallback = egress.spec.egress.find((r: { to: { ipBlock?: { cidr: string } }[] }) =>
|
||||
r.to.some((t) => t.ipBlock?.cidr === "0.0.0.0/0"),
|
||||
);
|
||||
expect(fallback).toBeDefined();
|
||||
expect(fallback.to[0].ipBlock.except).toEqual(
|
||||
expect.arrayContaining(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "127.0.0.0/8"]),
|
||||
);
|
||||
expect(fallback.ports).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ protocol: "TCP", port: 443 },
|
||||
{ protocol: "TCP", port: 80 },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT add the public-IPv4 fallback when operator supplied egressAllowCidrs", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests({
|
||||
...baseInput,
|
||||
egressAllowFqdns: ["api.anthropic.com"],
|
||||
egressAllowCidrs: ["203.0.113.0/24"],
|
||||
});
|
||||
const fallback = egress.spec.egress.find((r: { to: { ipBlock?: { cidr: string } }[] }) =>
|
||||
r.to.some((t) => t.ipBlock?.cidr === "0.0.0.0/0"),
|
||||
);
|
||||
expect(fallback).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT add the public-IPv4 fallback when no FQDNs are configured", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests(baseInput);
|
||||
const fallback = egress.spec.egress.find((r: { to: { ipBlock?: { cidr: string } }[] }) =>
|
||||
r.to.some((t) => t.ipBlock?.cidr === "0.0.0.0/0"),
|
||||
);
|
||||
expect(fallback).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the kube-client module so the plugin handlers run against injected
|
||||
// fake API clients instead of a real cluster. h.clients is swapped per test.
|
||||
const h = vi.hoisted(() => ({ clients: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock("../../src/kube-client.js", () => ({
|
||||
createKubeConfig: vi.fn(() => ({})),
|
||||
makeKubeClients: vi.fn(() => h.clients),
|
||||
}));
|
||||
|
||||
import plugin from "../../src/plugin.js";
|
||||
|
||||
const CONFIG = { inCluster: true, backend: "sandbox-cr" };
|
||||
|
||||
function leaseMetadata(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
namespace: "paperclip-acme",
|
||||
jobName: "pc-abc",
|
||||
podName: "pc-abc-pod",
|
||||
secretName: "pc-abc-env",
|
||||
phase: "Pending",
|
||||
backend: "sandbox-cr",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function notFound(): Error {
|
||||
return Object.assign(new Error("not found"), { code: 404 });
|
||||
}
|
||||
|
||||
function readySandboxCr(podName: string): Record<string, unknown> {
|
||||
return {
|
||||
metadata: { uid: "uid-1" },
|
||||
status: {
|
||||
conditions: [{ type: "Ready", status: "True" }],
|
||||
podName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.clients = {};
|
||||
});
|
||||
|
||||
describe("onEnvironmentResumeLease", () => {
|
||||
it("is implemented (Daytona feature parity)", () => {
|
||||
expect(plugin.definition.onEnvironmentResumeLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentDestroyLease).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("returns a valid lease handle for a live sandbox-cr lease", async () => {
|
||||
h.clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")),
|
||||
},
|
||||
core: {
|
||||
readNamespacedPod: vi.fn().mockResolvedValue({
|
||||
metadata: {},
|
||||
status: { phase: "Running" },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
config: CONFIG,
|
||||
providerLeaseId: "pc-abc",
|
||||
leaseMetadata: leaseMetadata(),
|
||||
});
|
||||
|
||||
expect(lease.providerLeaseId).toBe("pc-abc");
|
||||
expect(lease.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
namespace: "paperclip-acme",
|
||||
jobName: "pc-abc",
|
||||
podName: "pc-abc-pod",
|
||||
secretName: "pc-abc-env",
|
||||
phase: "Running",
|
||||
backend: "sandbox-cr",
|
||||
resumedLease: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns providerLeaseId null (expired) when the Sandbox CR is gone, so the caller falls back to acquireLease", async () => {
|
||||
h.clients = {
|
||||
custom: { getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
|
||||
core: { readNamespacedPod: vi.fn() },
|
||||
};
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
config: CONFIG,
|
||||
providerLeaseId: "pc-abc",
|
||||
leaseMetadata: leaseMetadata(),
|
||||
});
|
||||
|
||||
expect(lease.providerLeaseId).toBeNull();
|
||||
expect(lease.metadata?.expired).toBe(true);
|
||||
expect(lease.metadata?.reason).toMatch(/no longer exists/);
|
||||
});
|
||||
|
||||
it("returns providerLeaseId null when the backing pod is gone", async () => {
|
||||
h.clients = {
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr("pc-abc-pod")),
|
||||
},
|
||||
core: { readNamespacedPod: vi.fn().mockRejectedValue(notFound()) },
|
||||
};
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
config: CONFIG,
|
||||
providerLeaseId: "pc-abc",
|
||||
leaseMetadata: leaseMetadata(),
|
||||
});
|
||||
|
||||
expect(lease.providerLeaseId).toBeNull();
|
||||
expect(lease.metadata?.expired).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onEnvironmentDestroyLease", () => {
|
||||
it("deletes the Sandbox CR, pod, and per-run Secret", async () => {
|
||||
const deleteCr = vi.fn().mockResolvedValue({});
|
||||
const deletePod = vi.fn().mockResolvedValue({});
|
||||
const deleteSecret = vi.fn().mockResolvedValue({});
|
||||
h.clients = {
|
||||
custom: { deleteNamespacedCustomObject: deleteCr },
|
||||
core: { deleteNamespacedPod: deletePod, deleteNamespacedSecret: deleteSecret },
|
||||
batch: { deleteNamespacedJob: vi.fn() },
|
||||
};
|
||||
|
||||
await plugin.definition.onEnvironmentDestroyLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
config: CONFIG,
|
||||
providerLeaseId: "pc-abc",
|
||||
leaseMetadata: leaseMetadata(),
|
||||
});
|
||||
|
||||
expect(deleteCr).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "paperclip-acme", name: "pc-abc" }),
|
||||
);
|
||||
expect(deletePod).toHaveBeenCalledWith({
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc-pod",
|
||||
});
|
||||
expect(deleteSecret).toHaveBeenCalledWith({
|
||||
namespace: "paperclip-acme",
|
||||
name: "pc-abc-env",
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent: resolves cleanly when every resource is already gone (404)", async () => {
|
||||
h.clients = {
|
||||
custom: { deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
|
||||
core: {
|
||||
deleteNamespacedPod: vi.fn().mockRejectedValue(notFound()),
|
||||
deleteNamespacedSecret: vi.fn().mockRejectedValue(notFound()),
|
||||
},
|
||||
batch: { deleteNamespacedJob: vi.fn() },
|
||||
};
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentDestroyLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
config: CONFIG,
|
||||
providerLeaseId: "pc-abc",
|
||||
leaseMetadata: leaseMetadata(),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("is a no-op when providerLeaseId is null", async () => {
|
||||
const deleteCr = vi.fn();
|
||||
h.clients = {
|
||||
custom: { deleteNamespacedCustomObject: deleteCr },
|
||||
core: { deleteNamespacedPod: vi.fn(), deleteNamespacedSecret: vi.fn() },
|
||||
batch: { deleteNamespacedJob: vi.fn() },
|
||||
};
|
||||
|
||||
await plugin.definition.onEnvironmentDestroyLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
config: CONFIG,
|
||||
providerLeaseId: null,
|
||||
leaseMetadata: undefined,
|
||||
});
|
||||
|
||||
expect(deleteCr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the Job for job-backend leases", async () => {
|
||||
const deleteJob = vi.fn().mockResolvedValue({});
|
||||
const deleteCr = vi.fn();
|
||||
h.clients = {
|
||||
custom: { deleteNamespacedCustomObject: deleteCr },
|
||||
core: {
|
||||
deleteNamespacedPod: vi.fn().mockResolvedValue({}),
|
||||
deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
batch: { deleteNamespacedJob: deleteJob },
|
||||
};
|
||||
|
||||
await plugin.definition.onEnvironmentDestroyLease!({
|
||||
driverKey: "kubernetes",
|
||||
companyId: "acme",
|
||||
environmentId: "env-1",
|
||||
config: { inCluster: true, backend: "job" },
|
||||
providerLeaseId: "pc-job",
|
||||
leaseMetadata: leaseMetadata({ jobName: "pc-job", backend: "job", podName: "pc-job-pod", secretName: "pc-job-env" }),
|
||||
});
|
||||
|
||||
expect(deleteJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "paperclip-acme", name: "pc-job" }),
|
||||
);
|
||||
expect(deleteCr).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import plugin from "../../src/plugin.js";
|
||||
|
||||
describe("plugin", () => {
|
||||
it("exports the kubernetes driver", () => {
|
||||
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentValidateConfig).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("validateConfig accepts inCluster=true config", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("validateConfig rejects missing auth", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors?.[0]).toMatch(/requires one of `inCluster`/);
|
||||
});
|
||||
|
||||
it("validateConfig normalizes defaults", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.normalizedConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
namespacePrefix: "paperclip-",
|
||||
egressMode: "standard",
|
||||
jobTtlSecondsAfterFinished: 900,
|
||||
podActivityDeadlineSec: 3600,
|
||||
adapterType: "claude_local",
|
||||
backend: "sandbox-cr", // new default
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("validateConfig accepts backend=sandbox-cr explicitly", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, backend: "sandbox-cr" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.normalizedConfig?.backend).toBe("sandbox-cr");
|
||||
});
|
||||
|
||||
it("validateConfig accepts backend=job (stable fallback)", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, backend: "job" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.normalizedConfig?.backend).toBe("job");
|
||||
});
|
||||
|
||||
it("validateConfig rejects unknown backend value", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, backend: "kata-fc" },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("onHealth returns ok", async () => {
|
||||
const result = await plugin.definition.onHealth!();
|
||||
expect(result.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("validateConfig warns about FQDN limitation in standard mode", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, adapterType: "claude_local" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings?.some((w) => w.includes("api.anthropic.com"))).toBe(true);
|
||||
});
|
||||
|
||||
it("validateConfig does NOT warn when egressMode is cilium", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, adapterType: "claude_local", egressMode: "cilium" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.warnings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildJobManifest } from "../../src/pod-spec-builder.js";
|
||||
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
jobName: "r-01h00000000000000000000000",
|
||||
adapterType: "claude_local",
|
||||
image: "ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
envSecretName: "r-01h00000000000000000000000-env",
|
||||
serviceAccountName: "paperclip-tenant-sa",
|
||||
labels: { "paperclip.io/run-id": "r1" },
|
||||
resources: { requests: { cpu: "250m", memory: "512Mi" }, limits: { cpu: "2", memory: "4Gi" } },
|
||||
runtimeClassName: undefined,
|
||||
activeDeadlineSec: 3600,
|
||||
ttlSecondsAfterFinished: 900,
|
||||
};
|
||||
|
||||
describe("buildJobManifest", () => {
|
||||
it("returns a Job manifest with the correct apiVersion and kind", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.apiVersion).toBe("batch/v1");
|
||||
expect(job.kind).toBe("Job");
|
||||
});
|
||||
|
||||
it("sets Job-level lifecycle controls: backoffLimit=0, ttlSecondsAfterFinished, activeDeadlineSeconds", () => {
|
||||
const job = buildJobManifest({ ...baseInput, activeDeadlineSec: 1800, ttlSecondsAfterFinished: 600 });
|
||||
expect(job.spec.backoffLimit).toBe(0);
|
||||
expect(job.spec.ttlSecondsAfterFinished).toBe(600);
|
||||
expect(job.spec.activeDeadlineSeconds).toBe(1800);
|
||||
});
|
||||
|
||||
it("sets the security context to non-root, drop ALL caps, read-only rootFS, seccomp RuntimeDefault", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const podSec = job.spec.template.spec.securityContext;
|
||||
expect(podSec.runAsNonRoot).toBe(true);
|
||||
expect(podSec.runAsUser).toBe(1000);
|
||||
expect(podSec.fsGroupChangePolicy).toBe("OnRootMismatch");
|
||||
expect(podSec.seccompProfile.type).toBe("RuntimeDefault");
|
||||
|
||||
const container = job.spec.template.spec.containers[0];
|
||||
expect(container.securityContext.runAsNonRoot).toBe(true);
|
||||
expect(container.securityContext.readOnlyRootFilesystem).toBe(true);
|
||||
expect(container.securityContext.allowPrivilegeEscalation).toBe(false);
|
||||
expect(container.securityContext.capabilities.drop).toEqual(["ALL"]);
|
||||
});
|
||||
|
||||
it("wraps the entrypoint in tini for PID 1", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const container = job.spec.template.spec.containers[0];
|
||||
expect(container.command).toEqual(["/usr/bin/tini", "--", "/usr/local/bin/paperclip-agent-shim"]);
|
||||
});
|
||||
|
||||
it("declares explicit writable emptyDir mounts for the standard agent paths", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const mounts = job.spec.template.spec.containers[0].volumeMounts;
|
||||
const mountPaths = mounts.map((m: { mountPath: string }) => m.mountPath).sort();
|
||||
expect(mountPaths).toEqual(["/home/paperclip", "/home/paperclip/.cache", "/tmp", "/workspace"]);
|
||||
|
||||
const volumes = job.spec.template.spec.volumes;
|
||||
expect(volumes.every((v: { emptyDir?: unknown }) => v.emptyDir !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("envFrom references the per-run secret", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const envFrom = job.spec.template.spec.containers[0].envFrom;
|
||||
expect(envFrom[0].secretRef.name).toBe(baseInput.envSecretName);
|
||||
});
|
||||
|
||||
it("applies runtimeClassName when set", () => {
|
||||
const job = buildJobManifest({ ...baseInput, runtimeClassName: "kata-fc" });
|
||||
expect(job.spec.template.spec.runtimeClassName).toBe("kata-fc");
|
||||
});
|
||||
|
||||
it("does not set runtimeClassName when unset", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.spec.template.spec.runtimeClassName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets pod restartPolicy=Never (required for Job)", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.spec.template.spec.restartPolicy).toBe("Never");
|
||||
});
|
||||
|
||||
it("disables automountServiceAccountToken to avoid exposing an unnecessary SA token", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.spec.template.spec.automountServiceAccountToken).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the provided labels to both Job metadata and pod template", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.metadata.labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(job.spec.template.metadata.labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(job.spec.template.metadata.labels["paperclip.io/role"]).toBe("agent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildSandboxCrManifest } from "../../src/sandbox-cr-builder.js";
|
||||
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
sandboxName: "pc-01h00000000000000000000000",
|
||||
adapterType: "claude_local",
|
||||
image: "ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
envSecretName: "pc-01h00000000000000000000000-env",
|
||||
serviceAccountName: "paperclip-tenant-sa",
|
||||
labels: { "paperclip.io/run-id": "r1" },
|
||||
resources: {
|
||||
requests: { cpu: "250m", memory: "512Mi" },
|
||||
limits: { cpu: "2", memory: "4Gi" },
|
||||
},
|
||||
runtimeClassName: undefined,
|
||||
};
|
||||
|
||||
describe("buildSandboxCrManifest", () => {
|
||||
it("returns a Sandbox CR with the correct apiVersion and kind", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.apiVersion).toBe("agents.x-k8s.io/v1alpha1");
|
||||
expect(cr.kind).toBe("Sandbox");
|
||||
});
|
||||
|
||||
it("sets metadata name and namespace correctly", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.metadata.name).toBe(baseInput.sandboxName);
|
||||
expect(cr.metadata.namespace).toBe(baseInput.namespace);
|
||||
});
|
||||
|
||||
it("does NOT set ownerReferences (out-of-cluster server, explicit release path)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.metadata.ownerReferences).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets restartPolicy=Always on the pod template (required for long-lived Sandbox pod)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.restartPolicy).toBe("Always");
|
||||
});
|
||||
|
||||
it("uses sleep-infinity entrypoint via Tini for multi-command exec", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const container = cr.spec.podTemplate.spec.containers[0];
|
||||
expect(container.command).toEqual([
|
||||
"/usr/bin/tini",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"sleep infinity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies the same security baseline as Job backend (non-root, drop ALL, RO rootFS, seccomp)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const podSec = cr.spec.podTemplate.spec.securityContext;
|
||||
expect(podSec.runAsNonRoot).toBe(true);
|
||||
expect(podSec.runAsUser).toBe(1000);
|
||||
expect(podSec.fsGroupChangePolicy).toBe("OnRootMismatch");
|
||||
expect(podSec.seccompProfile.type).toBe("RuntimeDefault");
|
||||
|
||||
const container = cr.spec.podTemplate.spec.containers[0];
|
||||
expect(container.securityContext.runAsNonRoot).toBe(true);
|
||||
expect(container.securityContext.readOnlyRootFilesystem).toBe(true);
|
||||
expect(container.securityContext.allowPrivilegeEscalation).toBe(false);
|
||||
expect(container.securityContext.capabilities.drop).toEqual(["ALL"]);
|
||||
});
|
||||
|
||||
it("disables automountServiceAccountToken", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.automountServiceAccountToken).toBe(false);
|
||||
});
|
||||
|
||||
it("declares emptyDir volume mounts for standard agent paths", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const mounts = cr.spec.podTemplate.spec.containers[0].volumeMounts;
|
||||
const mountPaths = mounts
|
||||
.map((m: { mountPath: string }) => m.mountPath)
|
||||
.sort();
|
||||
expect(mountPaths).toEqual([
|
||||
"/home/paperclip",
|
||||
"/home/paperclip/.cache",
|
||||
"/tmp",
|
||||
"/workspace",
|
||||
]);
|
||||
|
||||
const volumes = cr.spec.podTemplate.spec.volumes;
|
||||
expect(
|
||||
volumes.every((v: { emptyDir?: unknown }) => v.emptyDir !== undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("envFrom references the per-run secret", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const envFrom = cr.spec.podTemplate.spec.containers[0].envFrom;
|
||||
expect(envFrom[0].secretRef.name).toBe(baseInput.envSecretName);
|
||||
});
|
||||
|
||||
it("applies runtimeClassName when set", () => {
|
||||
const cr = buildSandboxCrManifest({
|
||||
...baseInput,
|
||||
runtimeClassName: "kata-fc",
|
||||
});
|
||||
expect(cr.spec.podTemplate.spec.runtimeClassName).toBe("kata-fc");
|
||||
});
|
||||
|
||||
it("does not set runtimeClassName when unset", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.runtimeClassName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies provided labels to CR metadata and pod template labels (with role=agent added)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.metadata.labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(
|
||||
cr.spec.podTemplate.metadata.labels["paperclip.io/run-id"],
|
||||
).toBe("r1");
|
||||
expect(cr.spec.podTemplate.metadata.labels["paperclip.io/role"]).toBe(
|
||||
"agent",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies imagePullSecrets when provided", () => {
|
||||
const cr = buildSandboxCrManifest({
|
||||
...baseInput,
|
||||
imagePullSecrets: ["my-pull-secret"],
|
||||
});
|
||||
expect(cr.spec.podTemplate.spec.imagePullSecrets).toEqual([
|
||||
{ name: "my-pull-secret" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not set imagePullSecrets when not provided", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.imagePullSecrets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
createSandboxCr,
|
||||
deleteSandboxCr,
|
||||
getSandboxCrStatus,
|
||||
findPodForSandbox,
|
||||
SandboxCrTimeoutError,
|
||||
waitForSandboxReady,
|
||||
} from "../../src/sandbox-cr-orchestrator.js";
|
||||
|
||||
const SANDBOX_GROUP = "agents.x-k8s.io";
|
||||
const SANDBOX_VERSION = "v1alpha1";
|
||||
const SANDBOX_PLURAL = "sandboxes";
|
||||
|
||||
// Helpers to build mock CR objects with given phase
|
||||
function makeCr(phase: string, podName?: string): Record<string, unknown> {
|
||||
return {
|
||||
metadata: { uid: "sandbox-uid-123" },
|
||||
status: {
|
||||
phase,
|
||||
...(podName ? { podName } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSandboxCr", () => {
|
||||
it("calls custom.createNamespacedCustomObject with the correct params", async () => {
|
||||
const create = vi.fn().mockResolvedValue({ metadata: { uid: "test-uid" } });
|
||||
const clients = { custom: { createNamespacedCustomObject: create } };
|
||||
const manifest = {
|
||||
apiVersion: "agents.x-k8s.io/v1alpha1",
|
||||
kind: "Sandbox",
|
||||
metadata: { name: "pc-abc", namespace: "paperclip-acme" },
|
||||
};
|
||||
const result = await createSandboxCr(clients as never, "paperclip-acme", manifest);
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace: "paperclip-acme",
|
||||
plural: SANDBOX_PLURAL,
|
||||
body: manifest,
|
||||
});
|
||||
expect(result.uid).toBe("test-uid");
|
||||
});
|
||||
|
||||
it("throws if the API response has no UID", async () => {
|
||||
const create = vi.fn().mockResolvedValue({ metadata: {} });
|
||||
const clients = { custom: { createNamespacedCustomObject: create } };
|
||||
await expect(
|
||||
createSandboxCr(clients as never, "ns", {}),
|
||||
).rejects.toThrow("Sandbox CR created without a UID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSandboxCrStatus", () => {
|
||||
it("maps phase=Ready to SandboxStatus.phase=Running with active=1", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Ready"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Running");
|
||||
expect(status.active).toBe(1);
|
||||
expect(status.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("maps phase=Pending to SandboxStatus.phase=Pending", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Pending");
|
||||
expect(status.active).toBe(0);
|
||||
});
|
||||
|
||||
it("maps phase=Failed to SandboxStatus.phase=Failed with failed=1", async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
metadata: { uid: "uid-1" },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
conditions: [
|
||||
{ type: "Failed", reason: "ImagePullFailed", message: "no image" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Failed");
|
||||
expect(status.failed).toBe(1);
|
||||
expect(status.reason).toBe("ImagePullFailed");
|
||||
});
|
||||
|
||||
it("maps phase=Terminating to SandboxStatus.phase=Running with reason=Terminating", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Terminating"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Running");
|
||||
expect(status.reason).toBe("Terminating");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPodForSandbox", () => {
|
||||
it("returns status.podName from the Sandbox CR when set", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Ready", "pc-abc-pod-xyz"));
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: get },
|
||||
core: { listNamespacedPod: vi.fn() },
|
||||
};
|
||||
const podName = await findPodForSandbox(clients as never, "ns", "pc-abc");
|
||||
expect(podName).toBe("pc-abc-pod-xyz");
|
||||
// Should NOT have called listNamespacedPod (primary path succeeded)
|
||||
expect(clients.core.listNamespacedPod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to pod listing scoped by the unique sandbox-name label", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending")); // no podName
|
||||
const list = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "pc-abc-001", labels: { "agents.x-k8s.io/sandbox-name": "pc-abc" } },
|
||||
status: { phase: "Running" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: get },
|
||||
core: { listNamespacedPod: list },
|
||||
};
|
||||
const podName = await findPodForSandbox(clients as never, "ns", "pc-abc");
|
||||
expect(list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ labelSelector: "agents.x-k8s.io/sandbox-name=pc-abc" }),
|
||||
);
|
||||
expect(podName).toBe("pc-abc-001");
|
||||
});
|
||||
|
||||
it("never matches another sandbox's pod by name prefix", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending"));
|
||||
const list = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
// Same name prefix, different sandbox label: must NOT match.
|
||||
metadata: { name: "pc-abc-zzz", labels: { "agents.x-k8s.io/sandbox-name": "pc-abc-zzz" } },
|
||||
status: { phase: "Running" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: get },
|
||||
core: { listNamespacedPod: list },
|
||||
};
|
||||
const podName = await findPodForSandbox(clients as never, "ns", "pc-abc");
|
||||
expect(podName).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no pod is found in fallback", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending"));
|
||||
const list = vi.fn().mockResolvedValue({ items: [] });
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: get },
|
||||
core: { listNamespacedPod: list },
|
||||
};
|
||||
const podName = await findPodForSandbox(clients as never, "ns", "pc-abc");
|
||||
expect(podName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSandboxCr", () => {
|
||||
it("calls custom.deleteNamespacedCustomObject with Foreground propagation", async () => {
|
||||
const del = vi.fn().mockResolvedValue({});
|
||||
const clients = { custom: { deleteNamespacedCustomObject: del } };
|
||||
await deleteSandboxCr(clients as never, "ns", "pc-abc");
|
||||
expect(del).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace: "ns",
|
||||
plural: SANDBOX_PLURAL,
|
||||
name: "pc-abc",
|
||||
propagationPolicy: "Foreground",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForSandboxReady", () => {
|
||||
it("resolves immediately when Sandbox is already Ready", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Ready"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await waitForSandboxReady(
|
||||
clients as never,
|
||||
"ns",
|
||||
"pc-abc",
|
||||
{ timeoutMs: 5000, pollMs: 10 },
|
||||
);
|
||||
expect(status.phase).toBe("Running"); // Ready maps to Running
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("polls until Ready", async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeCr("Pending"))
|
||||
.mockResolvedValueOnce(makeCr("Pending"))
|
||||
.mockResolvedValueOnce(makeCr("Ready"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await waitForSandboxReady(
|
||||
clients as never,
|
||||
"ns",
|
||||
"pc-abc",
|
||||
{ timeoutMs: 5000, pollMs: 10 },
|
||||
);
|
||||
expect(status.phase).toBe("Running");
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("throws SandboxCrTimeoutError when deadline is exceeded", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
await expect(
|
||||
waitForSandboxReady(clients as never, "ns", "pc-abc", {
|
||||
timeoutMs: 50,
|
||||
pollMs: 10,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SandboxCrTimeoutError);
|
||||
});
|
||||
|
||||
it("throws an error describing the failure when Sandbox fails", async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
metadata: { uid: "u1" },
|
||||
status: { phase: "Failed", conditions: [{ type: "Failed", reason: "OOMKilled" }] },
|
||||
});
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
await expect(
|
||||
waitForSandboxReady(clients as never, "ns", "pc-abc", {
|
||||
timeoutMs: 5000,
|
||||
pollMs: 10,
|
||||
}),
|
||||
).rejects.toThrow(/failed.*OOMKilled/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createPerRunSecret } from "../../src/secret-manager.js";
|
||||
|
||||
describe("createPerRunSecret", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
secretName: "r-abcd-env",
|
||||
runId: "r-abcd",
|
||||
ownerKind: "Job",
|
||||
ownerApiVersion: "batch/v1",
|
||||
ownerName: "r-abcd",
|
||||
ownerUid: "11111111-1111-1111-1111-111111111111",
|
||||
bootstrapToken: "tok-xyz",
|
||||
adapterEnv: { ANTHROPIC_API_KEY: "sk-test" },
|
||||
};
|
||||
|
||||
it("creates a Secret with the correct name and namespace", async () => {
|
||||
const created: { body: Record<string, unknown> }[] = [];
|
||||
const clients = {
|
||||
core: { createNamespacedSecret: vi.fn(async (args: { body: Record<string, unknown> }) => { created.push(args); }) },
|
||||
};
|
||||
await createPerRunSecret(clients as never, baseInput);
|
||||
expect(clients.core.createNamespacedSecret).toHaveBeenCalledOnce();
|
||||
const body = created[0].body as { metadata: { name: string; namespace: string } };
|
||||
expect(body.metadata.name).toBe("r-abcd-env");
|
||||
expect(body.metadata.namespace).toBe("paperclip-acme");
|
||||
});
|
||||
|
||||
it("includes BOOTSTRAP_TOKEN and adapter env keys in stringData", async () => {
|
||||
const created: { body: Record<string, unknown> }[] = [];
|
||||
const clients = {
|
||||
core: { createNamespacedSecret: vi.fn(async (args: { body: Record<string, unknown> }) => { created.push(args); }) },
|
||||
};
|
||||
await createPerRunSecret(clients as never, baseInput);
|
||||
const body = created[0].body as { stringData: Record<string, string> };
|
||||
expect(body.stringData.BOOTSTRAP_TOKEN).toBe("tok-xyz");
|
||||
expect(body.stringData.ANTHROPIC_API_KEY).toBe("sk-test");
|
||||
});
|
||||
|
||||
it("sets ownerReferences to the owner resource for cascade delete", async () => {
|
||||
const created: { body: Record<string, unknown> }[] = [];
|
||||
const clients = {
|
||||
core: { createNamespacedSecret: vi.fn(async (args: { body: Record<string, unknown> }) => { created.push(args); }) },
|
||||
};
|
||||
await createPerRunSecret(clients as never, baseInput);
|
||||
const body = created[0].body as { metadata: { ownerReferences: { uid: string; controller: boolean }[] } };
|
||||
expect(body.metadata.ownerReferences).toHaveLength(1);
|
||||
expect(body.metadata.ownerReferences[0].uid).toBe("11111111-1111-1111-1111-111111111111");
|
||||
expect(body.metadata.ownerReferences[0].controller).toBe(true);
|
||||
});
|
||||
|
||||
it("throws if adapterEnv contains BOOTSTRAP_TOKEN", async () => {
|
||||
const clients = { core: { createNamespacedSecret: vi.fn() } };
|
||||
await expect(
|
||||
createPerRunSecret(clients as never, {
|
||||
...baseInput,
|
||||
adapterEnv: { BOOTSTRAP_TOKEN: "evil" },
|
||||
}),
|
||||
).rejects.toThrow(/BOOTSTRAP_TOKEN/);
|
||||
});
|
||||
|
||||
it("throws if ownerUid is empty", async () => {
|
||||
const clients = { core: { createNamespacedSecret: vi.fn() } };
|
||||
await expect(
|
||||
createPerRunSecret(clients as never, { ...baseInput, ownerUid: "" }),
|
||||
).rejects.toThrow(/ownerUid/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ensureTenant } from "../../src/tenant-orchestrator.js";
|
||||
|
||||
function makeMockClients() {
|
||||
const calls: { kind: string; name: string; namespace?: string; body?: unknown }[] = [];
|
||||
function track(kind: string) {
|
||||
return vi.fn(async (...args: unknown[]) => {
|
||||
const arg = (args[0] ?? {}) as { name?: string; namespace?: string; body?: unknown };
|
||||
calls.push({ kind, name: arg.name ?? "", namespace: arg.namespace, body: arg.body });
|
||||
return { body: arg.body };
|
||||
});
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
core: {
|
||||
createNamespace: track("Namespace"),
|
||||
readNamespacedServiceAccount: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedServiceAccount: track("ServiceAccount"),
|
||||
readNamespacedResourceQuota: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedResourceQuota: track("ResourceQuota"),
|
||||
readNamespacedLimitRange: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedLimitRange: track("LimitRange"),
|
||||
readNamespace: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
},
|
||||
rbac: {
|
||||
readNamespacedRole: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedRole: track("Role"),
|
||||
readNamespacedRoleBinding: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedRoleBinding: track("RoleBinding"),
|
||||
},
|
||||
networking: {
|
||||
readNamespacedNetworkPolicy: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedNetworkPolicy: track("NetworkPolicy"),
|
||||
},
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedCustomObject: track("CiliumNetworkPolicy"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ensureTenant", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
companyId: "11111111-1111-1111-1111-111111111111",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
serviceAccountAnnotations: {},
|
||||
egressMode: "standard" as const,
|
||||
egressAllowFqdns: ["api.anthropic.com"],
|
||||
egressAllowCidrs: [] as string[],
|
||||
resourceQuota: { pods: "20", requestsCpu: "5", requestsMemory: "20Gi", limitsCpu: "20", limitsMemory: "80Gi" },
|
||||
};
|
||||
|
||||
it("creates all required resources in the correct order on a fresh tenant", async () => {
|
||||
const clients = makeMockClients();
|
||||
await ensureTenant(clients as never, baseInput);
|
||||
const order = clients.calls.map((c) => c.kind);
|
||||
expect(order).toEqual([
|
||||
"Namespace",
|
||||
"ServiceAccount",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ResourceQuota",
|
||||
"LimitRange",
|
||||
"NetworkPolicy",
|
||||
"NetworkPolicy",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a CiliumNetworkPolicy instead of standard egress when egressMode=cilium", async () => {
|
||||
const clients = makeMockClients();
|
||||
await ensureTenant(clients as never, { ...baseInput, egressMode: "cilium" });
|
||||
const cnpCall = clients.calls.find((c) => c.kind === "CiliumNetworkPolicy");
|
||||
expect(cnpCall).toBeDefined();
|
||||
const npCalls = clients.calls.filter((c) => c.kind === "NetworkPolicy");
|
||||
expect(npCalls).toHaveLength(1);
|
||||
expect((npCalls[0].body as { metadata: { name: string } }).metadata.name).toBe("paperclip-deny-all");
|
||||
});
|
||||
|
||||
it("applies serviceAccountAnnotations to the ServiceAccount", async () => {
|
||||
const clients = makeMockClients();
|
||||
await ensureTenant(clients as never, {
|
||||
...baseInput,
|
||||
serviceAccountAnnotations: { "eks.amazonaws.com/role-arn": "arn:aws:iam::123:role/paperclip" },
|
||||
});
|
||||
const saCall = clients.calls.find((c) => c.kind === "ServiceAccount");
|
||||
const sa = saCall!.body as { metadata: { annotations: Record<string, string> } };
|
||||
expect(sa.metadata.annotations["eks.amazonaws.com/role-arn"]).toBe("arn:aws:iam::123:role/paperclip");
|
||||
});
|
||||
|
||||
it("skips creates that already exist (idempotency)", async () => {
|
||||
const clients = makeMockClients();
|
||||
clients.core.readNamespace.mockResolvedValue({ body: { metadata: { name: baseInput.namespace } } });
|
||||
await ensureTenant(clients as never, baseInput);
|
||||
expect(clients.core.createNamespace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates a 409 AlreadyExists from a concurrent ensure for the same tenant", async () => {
|
||||
const clients = makeMockClients();
|
||||
// Both racers saw the 404 read; the loser's create returns 409, which means
|
||||
// the desired state exists and must not fail the lease acquisition.
|
||||
clients.core.createNamespace.mockRejectedValue({ statusCode: 409 });
|
||||
clients.core.createNamespacedServiceAccount.mockRejectedValue({ code: 409 });
|
||||
await expect(ensureTenant(clients as never, baseInput)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { kubernetesProviderConfigSchema, parseKubernetesProviderConfig } from "../../src/types.js";
|
||||
|
||||
describe("kubernetesProviderConfigSchema", () => {
|
||||
it("accepts inCluster=true with no kubeconfig", () => {
|
||||
const parsed = parseKubernetesProviderConfig({ inCluster: true });
|
||||
expect(parsed.inCluster).toBe(true);
|
||||
expect(parsed.namespacePrefix).toBe("paperclip-");
|
||||
expect(parsed.imageAllowList).toEqual([]);
|
||||
expect(parsed.egressMode).toBe("standard");
|
||||
expect(parsed.jobTtlSecondsAfterFinished).toBe(900);
|
||||
});
|
||||
|
||||
it("accepts inline kubeconfig", () => {
|
||||
const parsed = parseKubernetesProviderConfig({
|
||||
inCluster: false,
|
||||
kubeconfig: "apiVersion: v1\nkind: Config\n",
|
||||
});
|
||||
expect(parsed.kubeconfig).toContain("apiVersion");
|
||||
});
|
||||
|
||||
it("rejects when neither inCluster nor any kubeconfig source is set", () => {
|
||||
expect(() => parseKubernetesProviderConfig({ inCluster: false })).toThrow(
|
||||
/requires one of `inCluster` or `kubeconfig`/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid companySlug", () => {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: true, companySlug: "INVALID UPPER" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects egressAllowCidrs entries that are not valid CIDR", () => {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: true, egressAllowCidrs: ["not-a-cidr"] }),
|
||||
).toThrow(/CIDR/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveCompanySlug, deriveNamespaceName, newRunUlidDns, paperclipLabels } from "../../src/utils.js";
|
||||
|
||||
describe("deriveCompanySlug", () => {
|
||||
it("lowercases and replaces non-alphanumerics", () => {
|
||||
expect(deriveCompanySlug("Acme Co!")).toBe("acme-co");
|
||||
});
|
||||
|
||||
it("preserves long inputs within the namespace budget instead of blind truncation", () => {
|
||||
const input = "a".repeat(40) + "-suffix-that-pushes-past-the-budget";
|
||||
const slug = deriveCompanySlug(input);
|
||||
expect(slug.length).toBeLessThanOrEqual(53);
|
||||
expect(slug.endsWith("-")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to 'company' on empty/zero-letter input", () => {
|
||||
expect(deriveCompanySlug("!!!")).toBe("company");
|
||||
expect(deriveCompanySlug("")).toBe("company");
|
||||
});
|
||||
it("keeps full UUIDs untruncated so distinct companies never share a slug", () => {
|
||||
const a = deriveCompanySlug("0d9c52f6-2f30-4d3e-9a01-aaaaaaaa0001");
|
||||
const b = deriveCompanySlug("0d9c52f6-2f30-4d3e-9a01-aaaaaaaa0002");
|
||||
expect(a).toBe("0d9c52f6-2f30-4d3e-9a01-aaaaaaaa0001");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("appends a hash of the full input when the slug exceeds the namespace budget", () => {
|
||||
const long1 = "x".repeat(60) + "-tail-one";
|
||||
const long2 = "x".repeat(60) + "-tail-two";
|
||||
const s1 = deriveCompanySlug(long1);
|
||||
const s2 = deriveCompanySlug(long2);
|
||||
expect(s1.length).toBeLessThanOrEqual(53);
|
||||
expect(s2.length).toBeLessThanOrEqual(53);
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveNamespaceName", () => {
|
||||
it("concatenates prefix and slug", () => {
|
||||
expect(deriveNamespaceName("paperclip-", "acme-co")).toBe("paperclip-acme-co");
|
||||
});
|
||||
});
|
||||
|
||||
describe("newRunUlidDns", () => {
|
||||
it("produces a DNS-safe 26-char lowercase id", () => {
|
||||
const id = newRunUlidDns();
|
||||
expect(id).toMatch(/^[a-z0-9]{26}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paperclipLabels", () => {
|
||||
it("returns canonical label map", () => {
|
||||
const labels = paperclipLabels({ runId: "r1", agentId: "a1", companyId: "c1", adapterType: "claude_local" });
|
||||
expect(labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(labels["paperclip.io/agent-id"]).toBe("a1");
|
||||
expect(labels["paperclip.io/company-id"]).toBe("c1");
|
||||
expect(labels["paperclip.io/adapter"]).toBe("claude_local");
|
||||
expect(labels["paperclip.io/managed-by"]).toBe("paperclip-k8s-plugin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { wrapCommandWithEnv } from "../../src/pod-exec.js";
|
||||
|
||||
describe("wrapCommandWithEnv", () => {
|
||||
it("returns the command unchanged when there is no env", () => {
|
||||
expect(wrapCommandWithEnv(["opencode", "run"], undefined)).toEqual(["opencode", "run"]);
|
||||
expect(wrapCommandWithEnv(["opencode", "run"], {})).toEqual(["opencode", "run"]);
|
||||
});
|
||||
|
||||
it("exports the env vars and execs the original command", () => {
|
||||
const out = wrapCommandWithEnv(
|
||||
["opencode", "run", "--model", "anthropic/x"],
|
||||
{ XDG_CONFIG_HOME: "/tmp/cfg", ANTHROPIC_API_KEY: "sk-bf-1" },
|
||||
);
|
||||
expect(out[0]).toBe("/bin/sh");
|
||||
expect(out[1]).toBe("-c");
|
||||
expect(out[2]).toContain("export XDG_CONFIG_HOME='/tmp/cfg';");
|
||||
expect(out[2]).toContain("export ANTHROPIC_API_KEY='sk-bf-1';");
|
||||
expect(out[2]).toContain("exec 'opencode' 'run' '--model' 'anthropic/x'");
|
||||
});
|
||||
|
||||
it("never propagates PATH (would break command resolution in the sandbox image)", () => {
|
||||
const out = wrapCommandWithEnv(["opencode"], { PATH: "/server/bin", XDG_CONFIG_HOME: "/c" });
|
||||
expect(out[2]).not.toContain("PATH=");
|
||||
expect(out[2]).toContain("XDG_CONFIG_HOME=");
|
||||
});
|
||||
|
||||
it("skips invalid identifiers and non-string values", () => {
|
||||
const out = wrapCommandWithEnv(["opencode"], {
|
||||
"BAD-KEY": "x",
|
||||
GOOD_KEY: "y",
|
||||
// @ts-expect-error intentional non-string to exercise the guard
|
||||
NUMERIC: 5,
|
||||
});
|
||||
expect(out[2]).toContain("export GOOD_KEY='y';");
|
||||
expect(out[2]).not.toContain("BAD-KEY");
|
||||
expect(out[2]).not.toContain("NUMERIC");
|
||||
});
|
||||
|
||||
it("shell-escapes single quotes in values", () => {
|
||||
const out = wrapCommandWithEnv(["opencode"], { V: "a'b" });
|
||||
expect(out[2]).toContain("export V='a'\\''b';");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user