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, at which point it isAvailableagain).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.
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/