Guide: Core Controller¶
This guide installs the banlieue main controller on a real Kubernetes
cluster from the released image ghcr.io/firestoned/banlieue:v0.4.0. The
controller watches VirtualMachine resources, schedules them onto a Provider,
creates the backend-specific infrastructure CR (e.g. VSphereMachine), and
mirrors status back. It is the foundation every provider builds on — install it
first, then add a provider with the vSphere Provider guide.
What you get after this guide
The CRDs, the controller Deployment (running, leader-elected), least-privilege
RBAC, and optional admission hardening. The controller alone will accept
VirtualMachines and try to schedule them, but nothing provisions until a
Provider and its provider controller exist.
Prerequisites¶
- A Kubernetes cluster, 1.30+ (1.30 is required only for the ValidatingAdmissionPolicies in step 6; the controller itself runs on 1.27+).
kubectlwith cluster-admin (you are installing CRDs, ClusterRoles, and cluster-scoped admission policies).-
The released manifests. Pin the repo to the tag so the YAML matches the image:
1. Install the CRDs¶
The CRDs are generated from the Rust types and committed at the release tag.
customresourcedefinition.apiextensions.k8s.io/providers.banlieue.io created
customresourcedefinition.apiextensions.k8s.io/providerclasses.banlieue.io created
customresourcedefinition.apiextensions.k8s.io/virtualmachines.banlieue.io created
customresourcedefinition.apiextensions.k8s.io/vmclasses.banlieue.io created
customresourcedefinition.apiextensions.k8s.io/vmimages.banlieue.io created
customresourcedefinition.apiextensions.k8s.io/vsphereclusters.infrastructure.banlieue.io created
customresourcedefinition.apiextensions.k8s.io/vspheremachines.infrastructure.banlieue.io created
customresourcedefinition.apiextensions.k8s.io/vspheremachinetemplates.infrastructure.banlieue.io created
Verify the API is registered:
2. Create the namespace¶
All banlieue workloads run in banlieue-system under the Pod Security
restricted profile.
# deploy/controller/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: banlieue-system
labels:
app.kubernetes.io/name: banlieue
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
3. Apply RBAC (least privilege)¶
The controller needs a ServiceAccount, a ClusterRole, and a binding. The role is scoped tightly:
banlieue.io— full reconcile onproviders,virtualmachines,vmclasses,vmimages(+ theirstatusand thefinalizerssubresources it writes).infrastructure.banlieue.io— full reconcile onvspheremachines/vspheremachinetemplates, but only watch + status onvsphereclusters(CAPI/the operator owns their lifecycle — the controller never creates or deletes them).events— create/patch (state transitions visible inkubectl describe).coordination.k8s.io/leases— leader election.
No secrets access at all (security review 2026-07-31 SEC-008): providers read
their own credentials through their per-instance Roles, and user-data travels
inside the infrastructure CRDs — the controller never reads a Secret.
This ClusterRole is not aggregated into Cluster API's manager. That is a
separate, smaller role, banlieue-capi-infrastructure
(deploy/capi/clusterrole-aggregate.yaml), which grants CAPI only the
infrastructure kinds (ADR-0096). See Cluster API.
4. Apply configuration¶
Runtime configuration is a ConfigMap consumed via envFrom:
# deploy/controller/configmap.yaml (data)
RUST_LOG: "info,kube=warn,hyper=warn,tower=warn"
RUST_LOG_FORMAT: "json" # structured logs for in-cluster
BANLIEUE_METRICS_PORT: "8080"
BANLIEUE_HEALTH_PORT: "8081"
5. Deploy the controller¶
The Deployment runs the single banlieue binary with the controller
subcommand (ADR-0004),
pinned to the released tag and hardened for the restricted PSS profile:
# deploy/controller/deployment.yaml (excerpt)
spec:
template:
spec:
serviceAccountName: banlieue-controller
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: controller
image: ghcr.io/firestoned/banlieue:v0.4.0 # pinned, never :latest
args: ["controller"] # role selector
envFrom:
- configMapRef: { name: banlieue-controller-config }
ports:
- { name: metrics, containerPort: 8080 }
- { name: health, containerPort: 8081 }
livenessProbe: { httpGet: { path: /livez, port: health } }
readinessProbe: { httpGet: { path: /readyz, port: health } }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
Wait for it to become ready:
6. (Recommended) Admission policies¶
banlieue ships optional ValidatingAdmissionPolicies that enforce invariants the CRD schema can't express — in the API server, via CEL, with no webhook to run or certificates to rotate (Kubernetes 1.30+):
| Policy | Enforces |
|---|---|
banlieue-virtualmachine-immutable-refs |
VirtualMachine.spec.classRef / imageRef are immutable after creation (changing class/image is a delete-and-recreate). |
banlieue-provider-immutable-class |
Provider.spec.providerClassRef.name is immutable. |
banlieue-provider-cabundle-source |
Provider.spec.connection.caBundle names exactly one of inline / configMapRef / secretRef. |
banlieue-provider-connection |
Provider.spec.connection.endpoint is https://, has no embedded userinfo, and insecureSkipTLSVerify requires an explicit opt-in annotation (CHAIN-001). |
banlieue-provider-credentialsref-authorization |
The principal creating/updating a Provider must itself be authorized to get the Secret named by spec.connection.credentialsRef (CHAIN-001). |
banlieue-providerclass-guardrails |
Bounds what a ProviderClass writer can hand a provider Deployment via spec.additionalRules / spec.workloadNamespace (SEC-006). |
banlieue-vmimage-import-source |
VMImage.spec.sources[].importFrom must be digest-pinned (@sha256:) and from an allow-listed registry (CHAIN-003). |
Each file pairs a ValidatingAdmissionPolicy with a binding whose
validationActions: ["Deny"] enforces it. To roll out in report-only mode
first, edit the binding to ["Warn", "Audit"], observe, then switch to Deny.
Confirm a violation is rejected (after you have a VM — see the provider guide):
kubectl patch virtualmachine db-prod-01 --type=merge -p '{"spec":{"imageRef":{"name":"something-else"}}}'
# Error from server: ... spec.imageRef.name is immutable (was ubuntu-22.04-cloudinit); delete and recreate ...
7. Verify¶
kubectl -n banlieue-system get deploy,pods
kubectl -n banlieue-system logs deploy/banlieue-controller | head
# Leader election lease acquired:
kubectl -n banlieue-system get lease banlieue-controller
# Metrics / health are reachable in-cluster:
kubectl -n banlieue-system port-forward deploy/banlieue-controller 8081:8081 8080:8080 &
curl -s localhost:8081/readyz # -> leader (or standby on a second replica)
curl -s localhost:8080/metrics | grep banlieue_leader
A healthy controller logs leader acquisition and a reconcile loop that idles until you create resources. What each endpoint reports, and how to scrape the metrics, is in the Observability guide.
What's next¶
The controller is running but has no backend to schedule onto. Add one:
- Cloud Hypervisor Provider, the reference backend: prepare a bare-metal KVM host and connect it, then run VMs on it.
- vSphere Provider: take a
VirtualMachineall the way to a running VM on vCenter. - libvirt Provider: register a libvirt/KVM host over mutual TLS and run VMs on it.
- Proxmox VE Provider: register a Proxmox node with a least-privilege API token and run VMs as template clones.
To uninstall, delete in reverse order (deploy/admission/, deploy/controller/,
then deploy/crds/ — deleting the CRDs removes all banlieue objects).