ADR 0008 · K8sFleet — a Kubernetes FleetProvider backend
- Status: accepted
- Date: 2026-07-04
- Source: k8s system-design spec (§"prospero changes", §"The two planes") in the caliban-ai docs hub · prospero #64 · epic caliban#274 · builds on prospero #71/#75 (caliband network transport) · relates to 0003, 0006, 0007
Context
ADR 0006 put fleet control behind the
FleetProvider trait (prospero #63); LocalFleet (caliband-over-Unix) is the only
backend. The k8s epic needs a second backend, K8sFleet, that drives a fleet by
CRUD + watch on CalibanTask custom resources — the caliban-operator (caliban
#283) reconciles each CalibanTask into a sandboxed caliband pod exposing a stable
DNS endpoint — and connects the live session plane to that pod over the network.
The network transport this needs already landed in prospero #71/#75
(caliband/transport.rs): CalibandClient can now dial a caliband over TCP + rustls
TLS + a bearer-token preamble (connect_tcp), spawn/attach return an
Endpoint, and AgentHandle.endpoint: Endpoint carries a Unix path or a
host:port. So K8sFleet composes an existing transport; it does not build one.
What remains for K8sFleet:
- a client-side
CalibanTasktype and akubeclient to CRUD/watch it; - the four
FleetProvidermethods mapped onto CR operations; - a session-plane bridge that dials each agent's
Endpoint::Tcp(Sandbox DNS) over #75's transport and feeds prospero's existing event bus + store, so the dashboard/SSE work unchanged; - reuse of the 0007 conformance suite, which is
Unix-
FakeCaliband-coupled and must be generalized to a fake backend.
Decision
-
Mirror a minimal
CalibanTasktype; do not depend on the caliban-operator crate. Per ADR 0003's "couple only through the wire" principle (here, the CRD's serialized form), declare a minimalkube::CustomResource(caliban.caliban-ai.dev/v1alpha1) inprospero-corecarrying only the fieldsK8sFleetsets (workspace.sources,task.prompt, optionalisolation) and reads (status.phase,status.calibandEndpoint,status.sandboxRef). A golden test pins it against a sample CR. The operator's CRD is the source of truth; the mirror is kept minimal to limit drift. -
K8sFleetimplementsFleetProvideroverCalibanTaskCRs. Newprospero-coremodule behind ak8scargo feature (soLocalFleet-only builds pull nokube).ensure_agent(spec)→ server-side-apply aCalibanTask(deterministic name from a hash of the spec, so it is idempotent); awaitstatus.phase = Running+status.calibandEndpoint; returnAgentHandle { endpoint: Endpoint::Tcp(calibandEndpoint) }.watch_fleet()→ akube::runtime::watcheronCalibanTask→ translate applied/deleted +phasetransitions intoFleetChange::{Discovered,StatusChanged,Gone}(mapPhase→AgentStatus), seeded by an initial list.stop_agent(id, drain)→ delete theCalibanTask(the operator's owner-ref GC tears down the Sandbox);Gracefulbest-effort awaits deletion within the timeout.restart_agent(id)→ delete + re-apply (fresh name → fresh id).
-
The session plane dials the agent
Endpointover #75's transport and feeds the existing bus + store.K8sFleetcarries its own attach task built onCalibandClient::connect_tcp+ the sharedstreamnormalizer +Emitter, so/streamSSE and history work unchanged (they read the bus/store, never a socket — ADR 0004). TLS root + bearer token come from operator-injected config (env/Secret; Sandbox DNS is the host). The attach-loop core is refactored out ofFleetManagerinto a provider-agnostic helper if cheaper than duplicating. -
Generalize the conformance suite behind a
FakeBackendtrait. Replacefleet_provider_conformance(provider, fake: &FakeCaliband)with(provider, backend: &dyn FakeBackend)whereFakeBackend { received_any_spec(); simulate_reap(id) }.FakeCalibandimplements it trivially (its existingreceived_specs/remove_agent); a new in-memoryFakeK8simplements it forK8sFleet.LocalFleet's existing conformance run is unchanged. This keeps ADR 0007's "test the control plane against a fake" property for both backends. -
Backend selection at the daemon edge.
prosperodchoosesLocalFleetvsK8sFleetby config/env (mirroring caliban's--database-urltopology switch — e.g.PROSPERO_FLEET=local|k8s+ namespace/kubeconfig). The API layer's remaining directFleetManagercalls (kill/respawn/steer/snapshot) are an ADR 0006 P1 limitation tracked separately;K8sFleetMVP wires the four provider methods + the session plane.
Consequences
- prospero gains a Kubernetes fleet backend —
kubectl-less fleet control viaCalibanTaskCRs, with live streaming over the pod network. This completes the epic's "two planes" for the k8s path (declarative CRs + real-time session over #75's transport). - prospero takes its first
kube/k8s-openapidependency, scoped toprospero-corebehind thek8sfeature;LocalFleetbuilds and runs with no cluster. - A second mirrored seam (the
CalibanTasktype vs the operator's CRD) joins the wire mirror of ADR 0003 — the same manual-sync tradeoff, kept minimal and golden-pinned. - The conformance suite becomes backend-agnostic, so future backends (remote — prospero #1) reuse it for free.
- Deferred: gRPC (caliban #314); rerouting the API's direct
FleetManagercalls through the provider seam; warm pools / multi-tenant (epic P4). The finalizer-drain / checkpoint pairing waits on caliban checkpoint gRPC.