c9s

Architecture

How Clabernetes controllers, direct device Pods, resources, and connectivity work together.

Overview

clabernetes is a collection of kubernetes custom resources and controllers that reconcile those resources. The ultimate goal of the controllers is to render a network topology in the cluster: one device Pod per (containerlab) node running the actual network-device image as a regular application container, network node interfaces stitched together across pods, and the management interfaces of the nodes exposed.

There is no nested runtime: no Docker daemon inside the Pod, no containerlab executable, and no image import path. The kubelet pulls and runs every device image natively, and Kubernetes owns device lifecycle, logs, resources, and status. Kind behavior (defaults, generated files, lifecycle commands, readiness) comes exclusively from an unmodified, pinned containerlab Go module, so a containerlab module bump admits new kinds without any clabernetes kind code.

Topology compiles into NodeProfile, Node, and Link resources; the node controller runs planning pods and renders device pods, while the link controller allocates tunnel ids consumed by each pod's connectivity sidecar to wire the pods together.

Everything below the "PRIMARY API" line works identically whether the custom resources were written by a person, by tooling, or by the Topology compiler.

A Node custom resource represents a single containerlab node: its spec is simply what a human would write for that node in a containerlab topology file (flat, verbatim containerlab vocabulary). A Link custom resource represents a single wire between two nodes. That's the whole authoritative api: apply Node and Link objects and the controllers render a running, wired lab, with no Topology object required.

The object name of a Node is the containerlab node name: the device pod hostname and the node's services derive from it, which also means the namespace is the topology boundary. Everything operational is controller-stamped into the statuses: expose port allocations and readiness on Nodes, wire id allocations on Links. Deployment policy (expose behavior, image pull defaults, primary-container resources, scheduling, persistence, probes) lives on NodeProfile (the retained CRD name for reusable direct-workload policy). Each Node can explicitly reference one same-namespace profile through spec.profileRef; an omitted reference uses global Config defaults, while a missing explicit reference prevents realization instead of silently falling back. Device privilege, capabilities, devices, and other kind-owned container requirements are not profile policy: they come from the imported package's plan.

Topology remains a supported, backward-compatible auxiliary resource. Its existing connectivity, expose, deployment, imagePull, and statusProbes fields are accepted and compiled into primitive resources. Because a Topology still embeds the whole source definition, large labs should define NodeProfile, Node, and Link resources directly instead of persisting a Topology.

Components

Controllers & Custom Resource Definitions

The "brains" of clabernetes is the manager deployment which runs three cooperating controllers:

  • the node controller turns every Node (or network-mode: container:<primary> group of Nodes) into one Recreate Deployment of a direct device Pod, a per-node "fabric" service (<name>-wire, headless peer discovery for cross-worker wires), an expose service (<name>), one headless service per declared network alias, and an optional PVC, and stamps readiness/allocations into the Node status. Before rendering, it resolves image metadata and runs planning (below); a Node whose plan fails stays failed with a structured diagnostic and never mutates the last successfully applied workload.
  • the link controller validates Links and allocates namespace-unique wire ids into their statuses (wire ids become the link identities on the fabric wire between connectivity sidecars; datagrams dispatch inside one receiving sidecar from a validated source, so the namespace is the whole allocation domain).
  • the topology controller is the optional convenience layer: it compiles a Topology (a containerlab file plus knobs) into NodeProfile/Link/Node objects, expanding topology defaults/kinds/groups into each node so every emitted Node is self contained. It prunes emitted objects that fall out of the definition, protects them from drift, and aggregates fixed-size counts and readiness back into the Topology status. Profiles and Links are reconciled before Nodes.

The compiler normally emits one shared NodeProfile and places its explicit reference on every Node. A distinct per-node workload resource policy produces a complete dedicated profile, not an inheritance chain.

Compilation fails closed: any unrecognized or unrepresentable Containerlab field fails before resource creation with sorted structured diagnostics naming every unsupported field and location. Deliberately rejected vocabulary fails with a diagnostic stating why, and only constructs whose loss cannot change lab behavior inside the cluster are accepted with a warning instead; see Differences from containerlab for the full list. Explicit veth links may use brief node:interface endpoints or equivalent structured node/interface mappings; empty or malformed endpoint values fail before Link resources are emitted.

Planning

Imported containerlab code mixes intent with side effects, so kind hooks never run inside the long-running manager. By default, a pool of four planning Pods runs in the manager namespace. Each worker handles one request at a time in a fresh, deadline-bounded subprocess with a private workspace. The controller first resolves metadata for each logical Node's declared image, then opens a private request/response stream through Kubernetes exec to an available worker. Inside it, the imported registry and node hooks run against a generic recording runtime and a disposable workspace: container creation, files, mounts, lifecycle, exec, namespace, and interface operations are recorded rather than performed, and the result is a deterministic, runtime-neutral device plan.

The plan's image configuration comes from a registry metadata resolver in the manager that fetches only manifests and config blobs (never layers) using same-namespace pull Secrets and the controller-only Config.spec.imagePull.registryMetadataTrust and registryMetadataMirrors policies. Assigning another imported role to an already-known image is handled in worker memory. If setup genuinely discovers a new image reference or certificate requirement, the worker asks the manager over the same attached stream; the manager resolves or issues it, and that process reruns the relevant evaluation without another Pod. The worker has no service-account token or network access. A repeated request that adds no facts and the overall session deadline fail the attempt instead of driving normal startup. The kubelet performs the only real image pull, and readiness fails on a material digest mismatch between the planned image and the kubelet-reported imageID.

The ordinary path is therefore: resolve declared-image metadata, use an existing worker, then create the device Pod. There are no separate image-discovery Pods. Accepted finalized inputs and plans are stored in immutable, digest-named, owner-referenced ConfigMaps that contain no secret bytes, so unchanged Nodes skip planning. Different Nodes keep separate plans even when their kind and image match: names, addresses, links, payloads, certificates, and entropy remain request-specific. Private payloads and entropy travel over the exec stream, and each request's workspace is removed before the worker is reused. Imported Go state is discarded with the subprocess. A kernel lock prevents overlapping requests, including across manager restarts. Each execution has a five-minute maximum lifetime; failed executions can be retried without retaining partial plans.

Set plannerPool.replicas in the Helm values to change parallel planning capacity. Excess requests wait in controller reconciliation without creating more Pods. The Node controller currently permits 16 concurrent reconciles, so replicas above 16 cannot increase concurrent planning without also raising that controller limit and validating the added load. Transferred payloads are limited to 64 MiB in total per request. Set plannerPool.enabled: false to use the original disposable planning Pod path, including when larger aggregate payloads are required. The device Pod's planner preparation init container remains necessary in either mode.

Each cold plan owns one bounded mutable connectivity-revision ConfigMap so live-capable Link changes update interfaces without rolling the Pod.

Direct device Pods

Each rendered Pod runs, in order:

  1. a preparation init container that re-runs the imported deterministic file generation against its mounted payload/certificate inputs, verifies every planned path, mode, and digest, and stages artifacts into plan-scoped volumes. It renders generated configuration with the runtime-completed management identity, so package templates emit the real management address, prefix, and gateway;
  2. a restartable privileged connectivity init-sidecar whose startup probe gates device start until local initialization, the accepted cold-start interface set, and imported endpoint hooks are complete. Missing remote peers keep its readiness probe failing without restarting the helper or blocking application startup. It stays running to reconcile live Link changes and publish connectivity readiness; and
  3. one regular application container per grouped logical Node or planned chassis component, running the unmodified device image. Kubernetes-native image pull policy, pull Secrets, environment, users, ports, resources, devices, capabilities, privilege, seccomp/AppArmor, sysctls, tmpfs, shared memory, and DNS all map from the plan onto native Pod fields.

A standalone Node has one Deployment/Pod; network-mode: container:<primary> adds application containers to the primary's Pod; a component-based node (i.e. a distributed SR-SIM chassis) expands into card containers that are directly visible as application containers of one Pod with validated namespace ownership. Container names are deterministic DNS labels derived from logical Node and component identity, so kubectl logs and kubectl exec target the actual device container (-c selects components).

VM-backed kinds stay ordinary application containers running their packaged qemu/vrnetlab entrypoint; the plan grants /dev/kvm, tap access, and resources, and clabernetes does not launch the VM itself.

Inter-Node Connectivity

The device always sees a plain interface leg; every kind tolerates that, including kinds that adopt the interfaces presented to them. All connectivity is realized by the Pod's own privileged connectivity sidecar; no node-resident agent exists. Before any device container starts, the sidecar preserves the CNI interface under a sidecar-owned name (c9s0), so the Kubernetes underlay survives whatever a device does to the interfaces it is shown.

Cross-pod wires terminate inside the Pod on that preserved underlay: each endpoint is a veth pair whose sidecar-side leg feeds the Pod's fabric wire, a sidecar-to-sidecar UDP transport (one socket per Pod) that segments whole frames to the local underlay MTU, reassembles them at the peer, propagates endpoint carrier state, and detects peer loss with heartbeats, all on one socket and one path so the carrier signal cannot diverge from the datapath in endpoint or route. Frames are addressed by the Link's allocated wire id; loss stays loss (nothing is retransmitted), so link MTUs never depend on the Pod network MTU and loss/convergence testing stays honest. Peer discovery uses the per-node headless fabric service (<remote node>-wire.<namespace>...), which publishes the current Pod address even before readiness; rewiring and Pod rescheduling converge on the sidecar's reconcile tick, and a peer that stops answering takes the link carrier-down like an unplugged cable. Same-Pod, loopback, and local veth state stays inside the Pod sandbox and is removed automatically with it. Host Links (host:<interface> endpoints) are veth pairs whose worker-side end the sidecar places through a read-only worker network-namespace handle (mounted only when the topology declares host Links); because a veth pair dies with either end, forced Pod deletion leaves no worker residue and nothing needs sweeping.

Management and DNS

Management identity is always addressed with containerlab semantics: every node's management address is controller-allocated, from the operator's management policy (profile mgmt subnets, static mgmt-ipv4/mgmt-ipv6), or from containerlab's default management subnet (172.20.20.0/24, gateway at the first usable address) when no policy is declared. The sidecar interposes a synthetic management interface carrying that address before the device starts: the device adopts it exactly as it would a physical management port, a sidecar-owned policy routing table keeps Kubernetes transport immune to device rewrites of the main table, and an nftables translation table (hooked ahead of anything a device programs) SNATs outbound management traffic to the Pod address and DNATs declared ports from it, so Service exposure keeps working. Inbound translated connections also get the Pod-local gateway as their source, so a device that only knows its connected management route (SR OS reads its routes from a Docker-shaped environment) can always answer. This is the same source identity containerlab's Docker port publishing presents. Management configuration renders at plan time through each kind's own imported templates using the allocated address, prefix, and gateway.

The management subnet is one shared subnet across the whole namespace, exactly as on containerlab's management network: a device dialing a peer's management address (a hardcoded telemetry target, a syslog collector, plain ping) reaches the peer device itself, any protocol, both directions, with source addresses intact. Nothing is flooded to get there: the sidecar's router leg answers the device's ARP for any peer with the gateway identity and routes the packet over a management VXLAN tunnel endpoint riding the preserved Pod underlay, holding exactly one neighbor entry and one forwarding entry per peer. Peers are published rather than discovered: the controller renders a namespace-wide peer directory (node names, aliases, management addresses, and the address of the Pod realizing each node) as a fixed set of ConfigMap shards that the kubelet projects into every Pod, so nodes joining, leaving, or moving between workers converge without any DNS lookup and without restarting any Pod (a rescheduled Pod's new address reaches its peers with the kubelet's ConfigMap sync, about a minute by default). The management gateway stays strictly Pod-local: every Pod answers the gateway address itself with one deterministic identity, and gateway traffic never crosses the mesh. Inside a device, node names, declared aliases, and chassis component names resolve to management addresses through the same peer directory written into each Pod's hosts file; the per-node and alias Services serve the rest of the cluster. Only ARP is emulated across Pods; other device broadcasts (gratuitous ARP, LLDP or DHCP on the management port) stay inside the Pod.

A device that firewalls its own network namespace (EOS installs a default-deny input policy) cannot sever these transports: the sidecar keeps accept rules for its mesh and wire UDP ports asserted at the head of any packet filter a device programs in the shared namespace, and re-asserts them on the reconcile tick when a device rewrites its firewall. On hardware the wire carrying management traffic is invisible to a control-plane ACL; these accepts preserve exactly that boundary, while the device keeps filtering its own management traffic above the transport.

Exposing Nodes

Ports listed in a node definition (plus a sensible default set unless auto-expose is disabled) are allocated into the Node status and exposed through a per-node service, LoadBalancer flavored by default, targeting the device Pod's ports directly. The assigned address is reflected back into the Node status.

Status and readiness

The node controller reads init/application container status directly and maps it back to each logical Node using applied plan identity and UID checks, so a stale Pod cannot update a replacement Node. OCI image healthchecks and declared healthcheck contracts translate into container startup/readiness behavior; profile statusProbes add TCP/SSH checks. Failures surface as bounded Node conditions and Kubernetes Events naming the logical Node, plan digest, and failing typed action.

On this page