The core, class-agnostic controller for IP addresses as a first-class resource (design: cozystack/community#35). It is the analog of the generic PVC/PV binding controller in the storage subsystem: it owns claim–address binding, status, and finalizer-driven cleanup, and leaves actual address allocation to per-class drivers (the CSI analogue), which live in separate projects.
The full contract — state machines, reconciliation algorithms, field ownership, and driver obligations — is specified in docs/design.md. The reference driver implementing it is metallb-iad.
All kinds live in the local.sdn.cozystack.io/v1alpha1 API group.
| storage | addresses | scope | role |
|---|---|---|---|
StorageClass |
IPAddressClass |
cluster | which pool, which driver (spec.provisioner), which reclaim policy |
PersistentVolume |
IPAddress |
cluster | the address itself, with a claimRef, a reclaim policy, and a fromClass/providerRef source union |
PersistentVolumeClaim |
IPAddressClaim |
namespaced | "give me one" — the whole tenant-facing API |
A tenant creates an IPAddressClaim, reads status.addresses[].address, and
puts it in DNS. The claim survives the workload: deleting the Service that
used the address never releases the address, because the address's lifetime
belongs to the claim, and (under reclaimPolicy: Retain) even outlives the
claim as a Released IPAddress.
- Claim:
Pending→Bound(all requested families bound) →Lost(a bound address disappeared). ADualclaim binds one IPv4 and one IPv6IPAddressand reports both instatus.addresses. - Address:
Pending→Available(noclaimRef) →Bound→Released(claim deleted underRetain; not reusable until an admin clearsspec.claimRef, or the class'sspec.releaseDelayelapses, at which point it isAvailableagain; an address annotatedipaddress.local.sdn.cozystack.io/held: "true"waits for the admin).ConflictandLostare driver-owned phases; the core controller treats them as sticky.
A driver is named by IPAddressClass.spec.provisioner and plugs into the
core controller as follows:
- Claim pickup. The core controller resolves a claim's class (explicit
spec.className, or the class annotatedipaddressclass.local.sdn.cozystack.io/is-default-class: "true") and stamps the claim with the annotationlocal.sdn.cozystack.io/provisioner: <provisioner>. The driver watches claims carrying its name and provisions for the ones not yetBound. - Provisioning. The driver allocates from the class's range
(
spec.source.fromClass: {}) or adopts a provider-side reservation (spec.source.providerRef.id), interpretingIPAddressClass.spec.parameters(opaque to the core). It creates theIPAddresswithspec.claimRefpre-set to the claim's namespace/name (UID optional — the core completes it),spec.reclaimPolicycopied from the class, and its own finalizer for backend teardown. - Binding. The core controller completes the binding: the claim goes
Boundwith the address instatus.addresses, the address goesBound. Statically pre-provisionedAvailableaddresses (created by an admin or a driver ahead of demand) are matched to claims by class and family; a claim may pin a specific one viaspec.addressName. - Reclaim. When the claim is deleted the core controller either marks
the address
Released(Retain) or deletes theIPAddressobject (Delete); in the latter case the driver's finalizer must deallocate the backend resource before allowing the object to go away. - Association. Attaching a bound address to a workload is a separate,
reversible act and is entirely driver territory: the driver resolves the
Service annotation
local.sdn.cozystack.io/ip-address-claim(naming a claim in the Service's own namespace), writes the backend's pin annotation, and maintainsIPAddress.status.associatedTo. The driver also reconciles live Service assignments against the ledger and sets theConflictphase when an address is held by a Service its binding does not authorize, andLostwhen the backing allocation disappears.
A LoadBalancer Service that names no claim gets one provisioned for it, so plain Services — including those a tenant cluster's cloud provider creates on the host — draw metered, attributed addresses instead of nothing:
- Mark one class with
ipaddressclass.local.sdn.cozystack.io/is-auto-claim-class: "true", or name a class on the Service withlocal.sdn.cozystack.io/ip-address-class. - The core creates an
IPAddressClaimnamed after the Service, in its namespace and controlled by it, and sets the Service'slocal.sdn.cozystack.io/ip-address-claimannotation; the driver takes it from there. - Deleting the Service deletes the claim and its addresses, whatever the
class's
reclaimPolicy. This holds for any claim with a controller owner. To keep the address, remove the claim's owner reference; it is an ordinary claim from then on. - Skipped: Services that already name a claim, set
local.sdn.cozystack.io/auto-claim: "false", setspec.loadBalancerClassorspec.loadBalancerIP, carry a MetalLB pool, pin or sharing annotation, or already have an address.
Apart from auto-claim, the core controller never touches Services. It never parses class parameters and never puts a packet on a wire.
Packaged as a Helm chart at chart/address-controller
(no kustomize). The CRDs and the ClusterRole are generated by make manifests
straight into the chart — chart/address-controller/crds/ and
templates/role.yaml are controller-gen output, never edited by hand.
helm upgrade --install address-controller chart/address-controller \
--namespace address-controller-system --create-namespaceHelm installs the crds/ directory on first install but never upgrades it;
after an API change, make install-crds applies the current CRDs to the
cluster. helm uninstall leaves the CRDs (and every IPAddress ledger
entry) behind by design.
The controller image is published as
ghcr.io/cozystack/address-controller:main (plus main-<sha> and semver tags)
by the release workflow on every push to main.
A tenant who can create claims can set spec.addressName and bind any
Available address of a class by name. The chart can install a
ValidatingAdmissionPolicy (admissionPolicy.enabled, off by default)
that reserves setting, changing or removing the field to allowlisted
principals; writes that leave it unchanged pass. Scope it with
admissionPolicy.namespaceSelector, list admins and, in a brokered
deployment, the machinery that moves addresses between namespaces in
allowedUsernames/allowedGroups, and run it with
validationActions: [Warn, Audit] before Deny. Which classes a namespace
may claim from at all is not checked; that is the entitlement object the
design proposal leaves open.
With metrics.enabled, the controller serves metrics over HTTPS on port
8443 behind the Service address-controller-metrics. Scrapes are
authenticated and authorized against the API server, so the scraper's
identity needs the address-controller-metrics-reader ClusterRole, for
example:
kubectl create clusterrolebinding prometheus-address-controller-metrics \
--clusterrole=address-controller-metrics-reader \
--serviceaccount=<prometheus-namespace>:<prometheus-serviceaccount>Besides the controller-runtime metrics, two gauges count the ledger:
| metric | labels |
|---|---|
address_controller_ipaddresses |
class, phase |
address_controller_ipaddressclaims |
namespace, class (resolved), phase |
Claims are the billable unit, as PersistentVolumeClaims are for storage: a
claim holds its addresses whether or not a Service uses them, so
address_controller_ipaddressclaims{phase="Bound"} counts what each
namespace holds.
metrics.serviceMonitor.enabled and metrics.prometheusRule.enabled add a
ServiceMonitor and alerts where the monitoring.coreos.com API exists:
addresses in Conflict or Lost for 5 minutes, and claims Pending for
15 minutes. metrics.serviceMonitor.labels adds labels to the
ServiceMonitor, for a Prometheus serviceMonitorSelector.
make manifests generate # regenerate CRDs, ClusterRole, and deepcopy after API changes
make build # build the manager
go test ./internal/controller/
make run # run the controller locally
make docker-build docker-push # publish ghcr.io/cozystack/address-controller:<git-sha>
make helm-package # lint and package the chart into dist/