Skip to content

refactor(cni): extract galactic-ipam as a real delegated CNI IPAM plugin - #304

Open
privateip wants to merge 3 commits into
refactor/cni-chain-0-veth-tap-splitfrom
refactor/cni-chain-1-galactic-ipam
Open

refactor(cni): extract galactic-ipam as a real delegated CNI IPAM plugin#304
privateip wants to merge 3 commits into
refactor/cni-chain-0-veth-tap-splitfrom
refactor/cni-chain-1-galactic-ipam

Conversation

@privateip

@privateip privateip commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Stack (merge bottom to top):


Summary

Second branch in the CNI plugin-chain split stack (based on #303). galactic-ipam is now a real CNI IPAM delegation target (github.com/containernetworking/plugins/pkg/ipam.ExecAdd/ExecDel), execed by galactic-cni/galactic-tap-cni — not the in-process library call step 0 left it as.

Explicit IPAM contract

  • Whether IPAM runs at all is decided solely by "ipam" block presence in a master plugin's own config. No environment variable can trigger or suppress that anymore — the old GALACTIC_CNI_ENABLE_LOCAL_IPAM "enforce ipam block" check is gone.
  • ipv6_subnet/ipv4_subnet/address_families move inside the "ipam" block. Their CIDR validation moved with them, into galactic-ipam's own parseConf.
  • ipam.type now names the delegated binary, not a pool-vs-static mode selector. Mode is decided internally: static_ip presence selects static, otherwise ipv6_subnet/ipv4_subnet select pool mode.
  • enableLocalIPAMGALACTIC_IPAM_ENABLE_LOCAL_IPAM (internal/config/ipam.go), read only by galactic-ipam itself, and only fills a default IPv6 pool CIDR when "ipam" is present but under-specified — it can't manufacture the block anymore.

The unlock: IPv6 pool persistence

PoolAllocator tracked allocations in memory only — fine for ADD (each pool belongs to exactly one VPCAttachment), useless for DEL (fresh process, nothing to look up). Deallocation used to close that gap by reading the allocated value back out of the BGPAdvertisement CRD annotation galactic-bgp wrote — the one thing keeping galactic-ipam coupled to galactic-bgp's CRD naming and a Kubernetes client it has no other use for.

Fixed: PoolAllocator gets the same on-disk marker file IPv4PoolAllocator already had (keyed by containerID, flock-guarded, one shared lock dir for both families — ipam.DefaultLockDir). Both allocators gained DeallocateContainer(containerID) and a peek-only LookupContainer(containerID). galactic-ipam now looks its own allocations up locally — no k8s client, no crdnames, no bgpv1alpha1 import at all.

Verification

  • task linttask build ✅ (builds + stages galactic-ipam too) task test:unit
  • internal/cniipam at 91% coverage, including a real ADD → CHECK → DEL → CHECK round trip proving the self-contained-by-containerID design holds end to end.
  • task test:e2e — not run, same caveat as refactor(cni): split galactic-cni into veth/tap master plugins #303.

🤖 Generated with Claude Code

@privateip
privateip requested a review from a team as a code owner August 8, 2026 03:49
@privateip
privateip requested review from mksinghtx and removed request for a team August 8, 2026 03:49
@privateip
privateip force-pushed the refactor/cni-chain-1-galactic-ipam branch from d5959ba to 2b74a81 Compare August 8, 2026 12:01
@privateip
privateip force-pushed the refactor/cni-chain-1-galactic-ipam branch from 2b74a81 to f226227 Compare August 8, 2026 12:15
privateip added a commit that referenced this pull request Aug 8, 2026
…ation

This step rewired galactic-cni/galactic-tap-cni's own IPAM handling
from an in-process ipam.Allocate() call into real CNI IPAM delegation
(ipam.ExecAdd, execing whatever binary "ipam.type" names). The e2e
test's config still had the old "ipam": {"type": "pool"} block from
before this step -- "pool" is no longer a mode selector, it's now
looked up as a literal binary name, so ADD failed with "failed to find
plugin \"pool\" in path [/opt/cni/bin]" (confirmed on PR #303/#304's
own CI run, the first time e2e has actually executed against a live
cluster in this whole plugin-chain-split stack, now that a separate
pre-existing eBPF-artifact-drift issue blocking Build no longer blocks
it).

Fixed to "ipam": {"type": "galactic-ipam", "ipv6_subnet": "..."} --
the explicit contract this step establishes (see internal/cniipam's
doc comment). Also fixed CNI_PATH from /opt/cni/bin to / so
ipam.ExecAdd's lookup can actually find /galactic-ipam: every binary
in the chain lands at the image root
(containers/galactic-cni/Dockerfile), not /opt/cni/bin -- that path
only exists on a real host once installer.Bootstrap's init container
stages it there, which this test's pod never runs. Dropped
GALACTIC_CNI_ENABLE_LOCAL_IPAM=true from the script: it's now inert
for this config (an explicit ipv6_subnet doesn't need the local-IPAM
fallback), though harmless either way since this step doesn't remove
the env var itself.

BGP/SRv6/eBPF publish is still inline in galactic-tap-cni's own cmdAdd
at this point in the split (that only moves out to its own chained
binary in a later step), so the eBPF control daemon startup, bpf-fs
mount, and hostNetwork pod spec stay exactly as they were -- this
config still needs the same BGPRouter fixture and pre-pinned eBPF maps
the previous step's fix already established.

Verification: task lint (0 issues), go build/vet clean, task test:unit
full suite green. Not verified against a live cluster locally;
pushing to let CI's own test-e2e job confirm, same as the previous
step's fix.
privateip added a commit that referenced this pull request Aug 8, 2026
"fd00:e2e:tap::/48" isn't a valid IPv6 literal -- "tap" contains 'p',
not a hex digit. cniipam's own parseConf validates ipv6_subnet with
net.ParseCIDR (see internal/cniipam/config.go's validateIPv6Subnet),
so ADD failed with "invalid CIDR value for field 'ipam.ipv6_subnet'"
as soon as this step's own IPAM-delegation fix (previous commit) made
that field's value actually reach real validation for the first time
-- it was never exercised before since PR #303/#304's e2e job never
got this far until the eBPF-artifact-drift and wrong-binary-target
issues in earlier commits on this branch were fixed.

Fixed to "fd00:e2e::/48": drops the invalid "tap" hextet, keeps "e2e"
(itself valid hex) as the mnemonic.

Verification: task lint (0 issues), go build/vet clean, task test:unit
full suite green, plus a plain net.ParseCIDR-equivalent check
(Python's ipaddress.ip_network) confirming the literal parses.
@mattdjenkinson

Copy link
Copy Markdown

Went through this one too. The delegation wiring itself is fine and the build/unit tests are clean, but moving IPAM state from in-memory/CRD-backed to durable on-disk marker files changes some failure-mode assumptions that the rest of the ADD/DEL/CHECK lifecycle wasn't updated to match. A few of these look like they should block merge, roughly in order of how bad I think they are.

Rollback on a failed ADD never releases the IPAM allocation. internal/cni/resource.go:74 (and the same gap in internal/cnitap/resource.go:68) tears down VRF, veth, BGP CRDs, and eBPF state on a failed ADD, but never calls ipam.ExecDel. Under the old in-memory scheme this didn't matter, the allocation just died with the process. Now it's a marker file on disk, so any ADD that gets past IPAM and then fails on a later step permanently burns a subnet or address out of the pool.

The checked-in NAD configs weren't migrated to the new shape. All ten nad.yaml files under deploy/containerlab/resources/tenants/** still set ipv6_subnet/address_families at the top level, but PluginConf.IPAM is now only populated from a nested ipam block. Those fields go unread, pluginConf.IPAM stays nil, and ADD succeeds while handing the workload an interface with no IP address at all. Nothing errors.

There's no migration path from the old CRD-annotation-based allocation record to the new on-disk markers. A pod allocated before this ships has no marker file. After the upgrade, a new pod's ADD scans on-disk state, doesn't see that old subnet as used, and can allocate it to someone else, so you end up with two live pods sharing an address.

Allocate(containerID) in internal/cni/ipam/ipam.go:142 (and the IPv4 equivalent in ipv4.go:107) doesn't check whether the container already holds an allocation before making a new one. The CNI spec explicitly permits a runtime to retry ADD for the same container after a transient failure, and each retry here writes a fresh marker file, leaking the earlier one. Since findContainerMarker returns on the first match, only one of the leaked markers is ever recoverable via DEL.

CHECK never validates IPAM state. internal/cni/ops_check.go:35's cmdCheck never calls ipam.ExecCheck, even though galactic-ipam implements it specifically for this. A lost or corrupted marker file goes completely undetected by CHECK.

GALACTIC_IPAM_ENABLE_LOCAL_IPAM gets re-read independently on every invocation in internal/cniipam/config.go:66. If that env var flips between a pod's ADD and its later DEL, deallocate() sees an empty subnet and skips cleanup silently, leaking the allocation with no error.

There's also a locking bug: DeallocateContainer in internal/cni/ipam/ipam.go:265 removes the marker file after findContainerMarker has already released the flock, so a cross-process caller can interleave between the scan and the removal. Allocate/Deallocate hold the lock across the whole read-modify-write; this path doesn't. ipv4.go has the same structure.

And a performance concern on top of that: the site-wide IPv4 pool can hold on the order of 4000 entries, and findContainerMarker in ipv4.go:200 reads every marker file's contents while holding the pool-wide flock. A single pod's DEL or CHECK can now block every other pod's ADD/DEL/CHECK against that pool for the duration, which is new lock-hold-time cost this PR introduces.

Two smaller things: DeallocateContainer/LookupContainer/findContainerMarker are duplicated almost verbatim between the IPv6 and IPv4 allocators, which live in the same package, so a shared helper would save having to patch both for any future fix to the scan/lock logic. And the new test names in internal/cniipam/config_test.go (like "missing ipam block rejected") use lowercase phrases rather than the UpperCamelCase the repo's conventions call for.

The allocation leak on failed ADD, the un-migrated NAD configs, and the missing upgrade path for existing allocations feel like the ones worth resolving before this merges, since they're silent correctness and availability problems rather than edge cases.

privateip and others added 3 commits August 8, 2026 15:02
Sequencing step 1 of the CNI plugin-chain split. galactic-ipam is now a
genuine CNI IPAM delegation target (github.com/containernetworking/plugins/
pkg/ipam.ExecAdd/ExecDel), not the in-process library call internal/cniipam
was left as at the end of step 0 — galactic-cni and galactic-tap-cni now
exec it, passing their own StdinData straight through (it already carries
the "ipam" block; the delegate ignores everything else in that JSON).

Lands the explicit IPAM contract the design note called for, ahead of any
real external IPAM system needing it:

- Whether IPAM runs at all is decided solely by whether "ipam" is present
  in a master plugin's own config — no environment variable can trigger or
  suppress that anymore. The old GALACTIC_CNI_ENABLE_LOCAL_IPAM "enforce
  ipam block" check in internal/cni/cnitap's parseConf is gone outright.
- ipv6_subnet/ipv4_subnet/address_families move inside the "ipam" block
  (internal/cniipam.IPAM's own fields) instead of living as PluginConf
  siblings. Their CIDR validation moved with them, into galactic-ipam's own
  parseConf — the master plugins no longer validate fields they don't own.
- ipam.type now names the delegated binary ("galactic-ipam"), not a
  pool-vs-static mode selector. Mode is decided internally, from field
  presence: static_ip selects the static path, otherwise ipv6_subnet/
  ipv4_subnet select pool mode (either family alone, or both).
- enableLocalIPAM is renamed GALACTIC_IPAM_ENABLE_LOCAL_IPAM
  (internal/config/ipam.go) and moves entirely into galactic-ipam's own
  process: it's read once, by the delegate, and only fills in a default
  IPv6 pool CIDR when "ipam" is present but specifies neither static_ip
  nor a subnet for either family. It can no longer manufacture an "ipam"
  block out of thin air.

The other half of this step — and the reason delegation could become
self-contained at all — is IPv6 pool persistence. PoolAllocator
(internal/cni/ipam) tracked allocations in memory only, which is silently
safe for ADD (each pool belongs to exactly one VPCAttachment, so there's
never a collision to guard against) but useless for DEL: a fresh process
has nothing to look up. Deallocation used to close that gap by reading the
allocated value back out of the BGPAdvertisement CRD annotation galactic-bgp
wrote — the one thing that would have kept galactic-ipam coupled to
galactic-bgp's CRD-naming scheme and a Kubernetes client it otherwise has
no use for at all. Fixed by giving PoolAllocator the same on-disk marker
file IPv4PoolAllocator already had (keyed by containerID, guarded by an
flock, one lock directory shared by both families now:
ipam.DefaultLockDir). Both allocators gained a DeallocateContainer(
containerID) and a peek-only LookupContainer(containerID), so galactic-ipam
now looks its own allocations up locally, and doesn't import a k8s client,
crdnames, or bgpv1alpha1 at all — decision 2 from the review notes, fully
realized: no kubeconfig, no RBAC surface, nothing.

internal/cniipam's shape changed accordingly: no more AllocConfig wrapper
(nothing needs vpc/vpcattachment/namespace once dealloc is local) — Allocate
and Deallocate/DeallocateContainer now take *IPAM directly. It gained its
own cmdAdd/cmdDel/cmdCheck/cmdStatus (github.com/containernetworking/cni/
pkg/skel) and CHECK logic that genuinely didn't exist before (confirming a
containerID's marker file is still present), plus a small result-conversion
helper (ResultToIPAMResult/BuildCNIResult) both sides of the delegation
boundary use to stay in the ips/routes-only shape the IPAM protocol expects
— no interfaces, ever; that stays the master plugin's job.

cmd/galactic-ipam, Taskfile.yaml, the galactic-cni Dockerfile, and
installer.Bootstrap all pick up the new binary the same way step 0 wired in
galactic-tap-cni.

Verified: task lint, task build, and task test:unit are all green
(internal/cniipam at 91% coverage, including a real ADD -> CHECK -> DEL ->
CHECK round trip proving the self-contained-by-containerID design holds
end to end). task test:e2e not run, same caveat as step 0's PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ation

This step rewired galactic-cni/galactic-tap-cni's own IPAM handling
from an in-process ipam.Allocate() call into real CNI IPAM delegation
(ipam.ExecAdd, execing whatever binary "ipam.type" names). The e2e
test's config still had the old "ipam": {"type": "pool"} block from
before this step -- "pool" is no longer a mode selector, it's now
looked up as a literal binary name, so ADD failed with "failed to find
plugin \"pool\" in path [/opt/cni/bin]" (confirmed on PR #303/#304's
own CI run, the first time e2e has actually executed against a live
cluster in this whole plugin-chain-split stack, now that a separate
pre-existing eBPF-artifact-drift issue blocking Build no longer blocks
it).

Fixed to "ipam": {"type": "galactic-ipam", "ipv6_subnet": "..."} --
the explicit contract this step establishes (see internal/cniipam's
doc comment). Also fixed CNI_PATH from /opt/cni/bin to / so
ipam.ExecAdd's lookup can actually find /galactic-ipam: every binary
in the chain lands at the image root
(containers/galactic-cni/Dockerfile), not /opt/cni/bin -- that path
only exists on a real host once installer.Bootstrap's init container
stages it there, which this test's pod never runs. Dropped
GALACTIC_CNI_ENABLE_LOCAL_IPAM=true from the script: it's now inert
for this config (an explicit ipv6_subnet doesn't need the local-IPAM
fallback), though harmless either way since this step doesn't remove
the env var itself.

BGP/SRv6/eBPF publish is still inline in galactic-tap-cni's own cmdAdd
at this point in the split (that only moves out to its own chained
binary in a later step), so the eBPF control daemon startup, bpf-fs
mount, and hostNetwork pod spec stay exactly as they were -- this
config still needs the same BGPRouter fixture and pre-pinned eBPF maps
the previous step's fix already established.

Verification: task lint (0 issues), go build/vet clean, task test:unit
full suite green. Not verified against a live cluster locally;
pushing to let CI's own test-e2e job confirm, same as the previous
step's fix.
"fd00:e2e:tap::/48" isn't a valid IPv6 literal -- "tap" contains 'p',
not a hex digit. cniipam's own parseConf validates ipv6_subnet with
net.ParseCIDR (see internal/cniipam/config.go's validateIPv6Subnet),
so ADD failed with "invalid CIDR value for field 'ipam.ipv6_subnet'"
as soon as this step's own IPAM-delegation fix (previous commit) made
that field's value actually reach real validation for the first time
-- it was never exercised before since PR #303/#304's e2e job never
got this far until the eBPF-artifact-drift and wrong-binary-target
issues in earlier commits on this branch were fixed.

Fixed to "fd00:e2e::/48": drops the invalid "tap" hextet, keeps "e2e"
(itself valid hex) as the mnemonic.

Verification: task lint (0 issues), go build/vet clean, task test:unit
full suite green, plus a plain net.ParseCIDR-equivalent check
(Python's ipaddress.ip_network) confirming the literal parses.
@privateip
privateip force-pushed the refactor/cni-chain-1-galactic-ipam branch from 1d28639 to 578d55b Compare August 8, 2026 19:03
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.

2 participants