design/ipam/ipam-datastore.md
The backend datastore layer for IPAM: the four CRDs and their KDD wrappers under libcalico-go/lib/backend/k8s/resources/ and
the model types under libcalico-go/lib/backend/model/. Cross-component picture is in the index. Paired with
ipam-core-library.md - the CAS protocol and sequence-number scheme are defined together; this file covers the datastore side.
An IPAMBlock is a contiguous slice of a pool, default /26 for IPv4 and /122 for IPv6. The block is the CAS unit: one object per slice, holding every per-IP record. Layout is in
libcalico-go/lib/backend/model/block.go; the design points that aren't obvious from the struct:
Unallocated queue is FIFO and that's load-bearing. Allocation pops the head; release pushes the tail. Cycling through the queue is what enforces rate-limited IP reuse -
a fresh allocation gets the IP that's been free the longest, not the IP that was just released. Code that punches the bitmap directly, bypassing the queue, breaks reuse delay.
See https://github.com/projectcalico/calico/issues/12638.SequenceNumber is paired with per-ordinal SequenceNumberForAllocation. Together they detect ABA on release; see Sequence numbers.Affinity is host:<name> or virtual:<name>. nil means unaffine. virtual: is used by the LoadBalancer controller; host: is the standard pod/tunnel case.Deleted is a soft-delete marker. Readers must filter; see Soft vs hard delete.AllocationAttribute.ReleasedAt records an IP in cooldown. A released IP is detached from its workload (handle cleared) but not yet returned to Unallocated: its attribute
carries a ReleasedAt timestamp and the ordinal stays in Allocations. It becomes reusable only once it is deallocated, after IPCooldownSeconds have elapsed. The
released-vs-deallocated split is the cooldown mechanism; the semantics live in ipam-core-library.Review notes
blockSize is immutable for existing blocks. Changing the pool's blockSize does not re-slice. https://github.com/projectcalico/calico/issues/10778.Unallocated queue cycling is load-bearing for IP-reuse delay. Code that punches the bitmap directly breaks reuse rate-limiting.*model.AllocationBlock before persisting. Persist via updateBlock, then update auxiliary state. See
https://github.com/projectcalico/calico/pull/12697.ReleasedAt. If a conversion drops it, a released IP reloads with no timestamp, never deallocates, and leaks permanently on the
default v1 datastore. The cooldown default of 0 hides this in CI, so a round-trip test through the KDD v1 backend is required.A BlockAffinity records that a host (or virtual owner) claims a block. State machine, driven by
ipam_block_reader_writer.go: ∅ → pending → confirmed → pendingDeletion → ∅.
pending - host wants the block, hasn't proven ownership. Treat as if absent for ownership / route decisions.confirmed - host owns the block. The block's Affinity field matches; route advertisement is safe.pendingDeletion - host is giving the block up. Other hosts must not re-claim until the affinity row is gone.Transitions only happen via ipam_block_reader_writer.go under CAS on resource version. The library is the only writer; the GC and calicoctl ipam release go through the same
entry points.
AffinityType defaults to "host" on read for pre-existing rows that lack the field. "virtual" is LoadBalancer affinity. See https://github.com/projectcalico/calico/pull/11179
Review notes
pending → confirmed is intentional - it's what makes claim races resolvable. Don't optimize it away.pending affinities as if absent. https://github.com/projectcalico/calico/pull/6003, https://github.com/projectcalico/calico/issues/1712.AffinityType; default to "host" on read.ipam_block_reader_writer.go, not a direct Update.The secondary index keyed by handle ID. Lets ReleaseByHandle and IPsByHandle answer "which allocations belong to this handle?" without scanning every block. Scan is O(blocks);
handle lookup is O(1).
Also enforces per-handle allocation caps for KubeVirt VM persistence: a live-migrating VM reuses the same handle ID across nodes, and the cap stops a buggy caller from piling allocations onto the same handle.
Handle ID format conventions live in ipam-core-library.md.
Watch is not supported - ipam_handle.go returns ErrorOperationNotSupported. kube-controllers reconciles via
the block syncer plus a handle-side scan rather than a watch. See https://github.com/projectcalico/calico/pull/12713.
Review notes
IPAMHandle row is the index; bypassing it loses the per-handle cap.Both stored as CRDs alongside the other IPAM resources. ipam_config.go wraps the singleton IPAMConfig. Storage
shape only - field semantics, defaults, and StrictAffinity / MaxBlocksPerHost / AutoAllocateBlocks interactions live in
ipam-core-library.md. IPReservation is read at allocation time and converted into an ordinal filter, and again by GetUtilization so that
the reporting surfaces don't count reserved addresses as free. kube-controllers watches it on its syncer instead, to keep the read off the IPAM sync loop. Never participates in CAS.
Review notes
ipam-core-library.md.Block and BlockAffinity CRD names encode the CIDR with dots/colons/slash replaced by - (see libcalico-go/lib/names/cidr.go). Two
design points: the encoding is one-way load-bearing - changing it strands every existing row, since lookup by the new name finds nothing and there is no migration path - and the
resulting name is not a stable host identifier, because BlockAffinity names exceed 253 chars on long hostnames and get truncated + SHA256-suffixed.
Review notes
The CAS-coordination contract has two halves: the library bumps and checks; the datastore stores. Library side is in
ipam-core-library.md.
Stored on every IPAMBlock:
SequenceNumber uint64 - block-level counter. Bumped by updateBlock before every persist.SequenceNumberForAllocation map[string]uint64 - key is ordinal-as-string, value is the block's SequenceNumber at the moment that ordinal was allocated.The per-ordinal map is what makes ABA detection possible: ordinal allocated to pod A → pod A deleted → ordinal reallocated to pod B → stale release op for pod A → stored per-ordinal sequence is pod B's, not pod A's → mismatch → release rejected → pod B keeps its IP. The block counter alone can't distinguish "released the same allocation twice" from "released a stale generation".
Sequence numbers live in the Spec, so they survive soft delete and object rename.
Review notes
SequenceNumber only ever goes up. Don't reset on retry; a reset reopens the ABA window.Kubernetes has no compare-and-delete by resource version. KDD works around this with a two-step pattern in
ipam_block.go, mirrored in the affinity and handle wrappers:
Update with Spec.Deleted = true. CAS on revision succeeds only if no other writer modified the row. This is the linearisation point.Delete by revision and UID. The K8s row goes away.Guarantees:
Deleted=true. Readers must filter Deleted=true client-side. A reader that skips the filter sees stale rows and races the
hard delete.Deleted is stored as bool in v3 CRDs and as string "true" / "false" in v1; ConvertFromK8s in
ipam_affinity_v3.go normalizes.
Review notes
Deleted=true client-side. Missing this is the bug class behind https://github.com/projectcalico/calico/pull/10855.Delete to "simplify" - you lose CAS on the delete and races return.KDD has no prefix-key matching. Any "list X for host Y" lookup must use a hashed-hostname label set by UpgradeHost(), called from CNI startup, calico-node startup, and
kube-controllers startup. A new label-dependent query that bypasses UpgradeHost() leaves pre-upgrade rows invisible. See https://github.com/projectcalico/calico/pull/10855.
Review notes
UpgradeHost() is the chokepoint; don't add a query that bypasses it.UpgradeHost() has run. Reconciliation that assumes the label is always present will miss them.IPAM CRDs exist on both crd.projectcalico.org/v1 (internal) and projectcalico.org/v3 (public). The two groups are not symmetric: v1 carries the storage shape every IPAM caller
has used since the beginning, while v3 exposes a user-facing surface with a different name (IPAMConfiguration vs IPAMConfig) and stricter field validation.
The full design of the CRD-group layout is owed its own doc; this section covers only the bits relevant to a reviewer touching the backend wrappers.
libcalico-go/lib/backend/k8s/resources/ speak v1. Conversion lives in ipam_*_v1.go / ipam_*_v3.go.calicoctl consume the wrapped types. New code outside the backend wrappers should not import v1 types - the lib/v3 → lib/internalapi rename
(https://github.com/projectcalico/calico/pull/11870) keeps that boundary clean.Review notes
crd.projectcalico.org/v1 types through new public APIs.IPAMConfig / IPAMConfiguration field needs the v3 type and conversion updated, not just v1. Operator RBAC and reconciliation usually need to follow -
https://github.com/tigera/operator/pull/4775, https://github.com/tigera/operator/pull/4776../ipam-core-library.md - paired. CAS retry, sequence-number checking, handle-ID conventions, and IPAMConfig semantics live there../ipam-gc.md - the GC depends on the BlockAffinity state machine and the soft-delete contract.