Skip to content

Core controller for IPAddressClass/IPAddressClaim/IPAddress (local.sdn.cozystack.io) - #1

Merged
lllamnyp merged 7 commits into
mainfrom
feat/core-controller
Jul 20, 2026
Merged

Core controller for IPAddressClass/IPAddressClaim/IPAddress (local.sdn.cozystack.io)#1
lllamnyp merged 7 commits into
mainfrom
feat/core-controller

Conversation

@lllamnyp

Copy link
Copy Markdown
Owner

Implements the core, class-agnostic controller for IP addresses as a first-class resource, per the design proposal in cozystack/community#35 — the analog of the generic PVC/PV binding controller in the storage subsystem. Per-class drivers (the CSI analogue) are a separate project and are out of scope here; this PR defines the contract they plug into.

Resource model

All kinds live in local.sdn.cozystack.io/v1alpha1 (the design doc sketches ipam.cozystack.io; this repo was pointed at the local.sdn group deliberately).

  • IPAddressClass (cluster-scoped): provisioner (immutable, names the driver), reclaimPolicy (Retain|Delete, default Retain), opaque parameters (preserved-unknown-fields object, never parsed by the core). The annotation ipaddressclass.local.sdn.cozystack.io/is-default-class: "true" marks the default class.
  • IPAddress (cluster-scoped): the inventory object — address (immutable), className, reclaimPolicy, claimRef (namespace/name/uid), and the source union (fromClass: {} vs providerRef: {id}, exactly-one enforced by CEL). Phases: Pending, Available, Bound, Released, Conflict, Lost. status.associatedTo names the workload the address is announced for; nil means reserved but inert.
  • IPAddressClaim (namespaced, the whole tenant-facing API): className (empty ⇒ default class), family (IPv4|IPv6|Dual), optional addressName pre-binding. status.addresses is a list so a Dual claim reports both families. Phases: Pending, Bound, Lost.

What the core controller does

Claim reconciler — resolves the class (spec → sticky status record → single default-annotated class; zero or multiple defaults surface as conditions and events), stamps the local.sdn.cozystack.io/provisioner annotation for drivers, accepts driver pre-bound addresses (completing the claimRef UID), matches Available addresses by class and family, and reports Bound/Pending/Lost with ClassResolved and Bound conditions. On deletion, a protection finalizer runs the reclaim flow: Retain marks the address Released (keeping claimRef until an admin clears it), Delete deletes the IPAddress so the driver's finalizer can tear down the backend.

Address reconciler — phase bookkeeping (AvailableBoundReleased, with Released + cleared claimRefAvailable again), deletion protection while a live claim holds the address, and a safety-net reclaim when the bound claim vanished without the claim-side flow running (including UID-mismatch stale bindings). Conflict and Lost are treated as driver-owned sticky states.

Per-class driver extension contract

  1. Driver watches claims stamped with its name in the local.sdn.cozystack.io/provisioner annotation and not yet Bound.
  2. It allocates (source.fromClass) or adopts (source.providerRef), interpreting class.spec.parameters, and creates the IPAddress with claimRef pre-set (UID optional — the core completes it), reclaimPolicy copied from the class, and its own finalizer for backend teardown.
  3. The core completes binding on both sides.
  4. On Delete reclaim the core deletes the IPAddress; the driver's finalizer deallocates before the object goes away.
  5. Association is driver territory: resolving the Service annotation local.sdn.cozystack.io/ip-address-claim, writing the backend pin annotation, maintaining status.associatedTo, and setting Conflict/Lost.

Decisions where the design is tentative (it is explicitly a stub on controller mechanics)

  • Provisioner signalling: PV-style annotation stamp (volume.kubernetes.io/storage-provisioner analog) rather than a driver-registration CRD. The design leaves in-tree vs out-of-tree open; the annotation contract works for both and doesn't foreclose a CRD-based capability registry later.
  • Provisioner capabilities (Allocate/Adopt/Pin from design §4) are not modeled in v1alpha1 — with no capability registry object there is nowhere to declare them yet. A class over a Pin-less backend must be rejected by its driver; revisit when the driver contract gets its own CRD.
  • parameters is an opaque object (preserve-unknown-fields), not StorageClass-style map[string]string, so the design's addresses: ["203.0.113.0/24"] example is valid as written.
  • Default-class conflicts (multiple annotated defaults) leave the claim Pending with a MultipleDefaultClasses condition instead of silently picking one (Kubernetes' newest-wins StorageClass behaviour felt too magical for an alpha API).
  • Dual-stack is two IPAddress objects bound to one claim — the direction open question 4 leans toward; the status list makes it representable either way.
  • spec.addressName on the claim (PVC volumeName analog) for pinning a specific Available address — implied by the "give me this one" discussion and the Released-address reuse flow, not spelled out in the design.
  • Claim resolution is sticky via status.className, since without an admission webhook the controller can't default spec.className at create time the way the PV admission plugin does.
  • UID-mismatched claimRef (claim deleted and recreated under the same name) is treated as a stale binding and reclaimed, never adopted by the new claim.
  • Association, Service watching, and conflict detection (design §§5–8) are left entirely to drivers: the backend pin annotation is per-backend by nature, and the core never touches Services. The API carries the fields (associatedTo, Conflict) so drivers converge on one shape. The ValidatingAdmissionPolicy from the Security section ships with the MetalLB driver, not here.

Testing

Hermetic fake-client unit tests for both reconcilers (class resolution and default handling, pre-bound and matched binding, dual-stack, both reclaim policies, deletion protection, sticky phases, stale-UID reclaim). The scaffolded envtest suite was dropped in favour of these; e2e belongs with the drivers.

🤖 Generated with Claude Code

lllamnyp added 4 commits July 19, 2026 01:02
kubebuilder init (domain sdn.cozystack.io, group local => API group
local.sdn.cozystack.io) plus stub kinds IPAddressClass, IPAddress,
IPAddressClaim and controllers for the latter two.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
The PersistentVolume pattern applied to addresses, per the design in
cozystack/community#35, under the local.sdn.cozystack.io group:

- IPAddressClass (cluster-scoped): provisioner, reclaimPolicy
  (Retain|Delete, default Retain), opaque driver parameters, and a
  default-class annotation.
- IPAddress (cluster-scoped): the inventory object, with the concrete
  address, a claimRef, a reclaim policy, and a fromClass/providerRef
  source union (allocate vs adopt). Phases: Pending, Available, Bound,
  Released, Conflict, Lost.
- IPAddressClaim (namespaced): the tenant-facing request; className
  (empty means default class), family (IPv4|IPv6|Dual), optional
  addressName pre-binding; status reports a list of bound addresses so
  a Dual claim can report both families.

Well-known annotations, finalizers, and condition types shared with
per-class drivers live in well_known.go. Deepcopy, CRDs, and samples
regenerated via make generate manifests.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
The class-agnostic half of the PV-pattern lifecycle:

IPAddressClaim reconciler
- resolves the claim's class (spec, then sticky status record, then the
  single default-annotated class; zero or multiple defaults surface as
  conditions) and stamps the provisioner annotation that per-class
  drivers key on,
- accepts driver pre-bound addresses (completing the claimRef UID),
  matches Available addresses by class and family, honours addressName
  pre-binding, and reports Bound/Pending/Lost with conditions,
- on deletion runs the reclaim flow behind a protection finalizer:
  Retain releases the address keeping its claimRef, Delete deletes the
  IPAddress object so the driver's finalizer can tear down the backend.

IPAddress reconciler
- phase bookkeeping between Available, Bound, and Released, treating the
  driver-owned Conflict and Lost phases as sticky,
- deletion protection while a live claim holds the address,
- safety-net reclaim when a claim vanished without the claim-side flow.

A shared field index maps claimRef to addresses. Unit tests use the fake
client, replacing the scaffolded envtest suite. RBAC regenerated.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
lllamnyp added 3 commits July 19, 2026 12:32
Records the resource model, the claim and address state machines, the
exact reconciliation and reclaim algorithms, the field-ownership matrix
between core and drivers, and the numbered driver obligations. Also
re-stamp the provisioner annotation when a claim is re-targeted at a
different class before binding, so the stamp always mirrors the resolved
class, as the doc specifies.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
Three review questions, answered in the doc and — where the honest
answer required it — in the algorithm:

- What reclaim actually reclaims: spelled out per policy and per address
  source in the reclaim-flow section, including that a Retained
  provider-held reservation deliberately keeps incurring charges until
  the object is deleted, and that Delete releases backend state through
  the driver's finalizer (adopted reservations being the driver's
  documented call).
- Why the IP lives in spec, not status: it is identity and intent, not
  an observation — for a range-carved address the ledger entry is the
  only record, so there is nothing to re-observe it from; PV precedent;
  and a backend that cannot pin a specific address cannot be a driver at
  all.
- Class deletion must not break satisfied claims: restructured the claim
  reconciler so binding state is collected before class resolution and
  the class is consulted only when families are missing. A fully bound
  claim now reconciles — including status upkeep — without its class;
  unresolved-class conditions are recorded in-memory and persisted by
  the single end-of-pass status update. Regression tests cover a Bound
  claim with its class deleted and a Pending claim naming a missing
  class.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
The previous argument overreached twice: it claimed the IP is never
re-observable (false for providerRef — describing the provider handle
returns it) and implied a provider without choose-at-allocation cannot
serve claims (conflating that with the Pin capability, which AWS-style
providers have natively via associate-by-handle).

The corrected rationale: the split is request vs. record — the claim is
the request, and an IPAddress is only ever created after allocation is
a fact, so the address is known at creation and no observe-later phase
exists; the field provides identity (name derivation, one-object-per-IP
via create collision, conflict indexing), which cannot live in a
mutable, wipeable status; the fromClass arm has no external record to
reconstruct from and the union shares one schema; and the precedent is
Service.spec.clusterIP — system-allocated yet spec — rather than
PV.spec. providerRef wording widened to cover driver-allocated-at-
provider reservations, not just adoption.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
@lllamnyp
lllamnyp marked this pull request as ready for review July 20, 2026 21:00
@lllamnyp
lllamnyp merged commit ac62790 into main Jul 20, 2026
4 of 6 checks passed
lllamnyp added a commit to lllamnyp/metallb-iad that referenced this pull request Jul 20, 2026
lllamnyp/address-controller#1 landed; the API now comes from main
(ac62790) instead of the feat/core-controller branch tip. Both repos are
public now, so GOPRIVATE is no longer needed to build.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant