Skip to content

design-proposal: public IPs as a first-class resource (PublicIP / PublicIPClaim / PublicIPClass)#35

Draft
Timofei Larkin (lllamnyp) wants to merge 2 commits into
mainfrom
design/public-ip-claims
Draft

design-proposal: public IPs as a first-class resource (PublicIP / PublicIPClaim / PublicIPClass)#35
Timofei Larkin (lllamnyp) wants to merge 2 commits into
mainfrom
design/public-ip-claims

Conversation

@lllamnyp

Copy link
Copy Markdown
Member

What

Adds a design proposal under design-proposals/public-ip-claims/ introducing public addresses as a first-class resource: a cluster-scoped PublicIP, a namespaced PublicIPClaim, a PublicIPClass, and a provisioner contract in the shape of CSI.

Status: stub. The problem statement, the primitives survey and the object model are worked out. The controller mechanics, the provisioner contract details and the rollout are deliberately sketched. It is opened early to settle the shapeis an address a resource? — before anyone writes code.

Why

A tenant cannot own a public address. One appears as a side effect of creating a Service type: LoadBalancer, and evaporates when that Service is deleted. So an address cannot be:

  • held across a workload rebuild (delete the Service, lose the address — someone else may get it),
  • moved from one workload to another during a cutover,
  • quota'd (there is no object to count; the only lever is pre-carving a pool per tenant),
  • gated (anyone who can create a Service can cause an address to be allocated — RBAC authorizes verbs on resources, not fields),
  • enumerated ("which addresses do we own, and who holds them?" has no answer short of listing every Service in the cluster).

The experience we want is the AWS one: allocate an address, keep it, attach it, detach it, attach it to something else, release it when done.

This is a regression, not a wishlist

Cozystack has reserve-then-associate today, via kube-ovn: OvnEip is created independently and later bound by an OvnFip / OvnSnatRule / OvnDnatRule that references it by name. Migrating off kube-ovn drops that capability unless something replaces it. The question is not "should Cozystack gain an AWS-style EIP" but "on what terms does it keep the one it already has."

The model

The PersistentVolume pattern, applied to addresses:

storage addresses role
StorageClass PublicIPClass which pool, which provisioner, which announcer
PersistentVolume PublicIP (cluster-scoped) the address, with a claimRef and a reclaim policy
PersistentVolumeClaim PublicIPClaim (namespaced) "give me one" — the whole tenant-facing API
CSI driver a provisioner contract how a class's backend allocates, adopts, and pins

Two things fall out of the survey and are worth a reviewer's attention:

  • An address source union (fromClass vs providerRef), directly analogous to PV's volume-source union. Self-allocating backends (MetalLB, Cilium, kube-vip) have no reservation concept anywhere, so our provisioner is the IPAM of record. Cloud backends (AWS, GCP) already hold the reservation with a stable handle and their own IAM, so the provisioner must adopt, not allocate. Surviving a backend where allocation isn't ours is the evidence the shape isn't bent around MetalLB.
  • A Pin capability, which the entire model depends on: a backend that cannot be told which address to use can never attach a reserved one, so a class over it must reject claims outright rather than allocate an address it can never bind.

The sharp edge (please read the Security section)

The per-backend "pin this IP" annotation (metallb.io/loadBalancerIPs, lbipam.cilium.io/ips, …) is a privilege-escalation surface. A self-allocating backend has no concept of a reservation, so it will hand a tenant an address that another tenant has reserved but not yet attached. That theft window exists on MetalLB and Cilium alike.

The fix is the move Cozystack already makes elsewhere: turn the ungatable field into a reference to an RBAC-gated object. Tenants may only write network.cozystack.io/public-ip-claim (naming an object in their own namespace); the controller writes the raw pin annotation; a ValidatingAdmissionPolicy rejects any other principal writing one. Without that policy the model is advisory, so it belongs in the same release as the CRDs.

Notes for reviewers

  • Adjacent to, but not dependent on, design-proposal: structured, additive external exposure for managed applications #29 (structured-external-exposure). That proposal restructures how an application requests exposure; this one is about what an address is. They meet at one point — an exposure entry ought to be able to name a claim instead of implicitly minting an address — but neither needs the other to land first, and this does not assume design-proposal: structured, additive external exposure for managed applications #29's shape.
  • ExposureClass is touched on but not depended on. The class idea is right and survives here in some form; binding an address's lifetime to a Service is the part that structurally cannot serve this use case. Open question 4 defers the merge-or-separate call, since that kind's future is being re-examined independently.
  • The datapath is out of scope. Whole-IP 1:1 NAT (all ports in, and the workload egressing as that address — today cozy-proxy's nftables rules) is a CNI concern tracked separately. This is the allocation half; that is the forwarding half. Allocation is useful on its own with a stock LoadBalancer Service.
  • Nothing upstream is coming. KEP-1880 shipped ServiceCIDR/IPAddress and explicitly named "any generalization onto something like an IPAM API" a non-goal; loadBalancerIP was deprecated in 1.24 with no core replacement. Cluster API's IPAddressClaim/IPAddress is literally this pattern but has only ever been wired to Machine addressing — open question 3 asks whether we adopt it or merely copy the contract.

The test that is the proposal

Claim → bind → associate → delete the Service → the address is still held → associate it to a different workload → the same address comes back.

That single integration test is precisely what cannot pass today.

…cIPClaim/PublicIPClass)

A tenant cannot own a public address today: it exists only as a side effect of a
Service type: LoadBalancer and evaporates with it. Nothing can be held across a
workload rebuild, moved between workloads, quota'd, or enumerated.

Applies the PersistentVolume pattern to addresses -- PublicIPClass (the class),
PublicIP (the cluster-scoped inventory object), PublicIPClaim (the namespaced
request) -- plus a provisioner contract in the shape of CSI, with an explicit Pin
capability that the whole model depends on.

Records that this is a regression rather than a wishlist: Cozystack has
reserve-then-associate today through kube-ovn's OvnEip, and migrating off kube-ovn
drops it.

Stub: object model and primitives survey are worked out; controller mechanics,
provisioner contract details and rollout are sketched, and opened early to settle
the shape before code.

Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 748af431-21e2-49d6-b39d-a6bf6afb3aba

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch design/public-ip-claims

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a design proposal to treat public IPs as first-class resources in Cozystack, modeling them after the Kubernetes PersistentVolume pattern with PublicIP, PublicIPClaim, and PublicIPClass resources. The feedback highlights three key areas for improvement: updating the PublicIPClaim status schema to support dual-stack scenarios by using a list of IP objects, configuring MetalLB's IPAddressPool with autoAssign: false to prevent IP conflicts with standard services, and clarifying how the ValidatingAdmissionPolicy will securely identify the controller principal.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +197 to +200
status:
phase: Bound
publicIPName: pip-203-0-113-7
address: 203.0.113.7 # what the tenant reads, and puts in DNS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a Dual stack claim binds to two separate PublicIP objects (as suggested in Open Question 5), the current status schema for PublicIPClaim is insufficient because publicIPName and address are single scalar strings.

To support dual-stack where one claim maps to multiple PublicIP resources, the status should be designed to accommodate multiple names and addresses (e.g., using a list of IP status objects).

Suggested change
status:
phase: Bound
publicIPName: pip-203-0-113-7
address: 203.0.113.7 # what the tenant reads, and puts in DNS
status:
phase: Bound
publicIPs:
- name: pip-203-0-113-7
address: 203.0.113.7

Comment on lines +286 to +292
For MetalLB, forever, for the whole cluster:

- **one** `IPAddressPool` covering the range, and
- **one** `L2Advertisement` (or `BGPAdvertisement`) selecting it.

That is all. No pool per tenant, no `/32`s. The `PublicIPClass` carries the range; the
provisioner renders the backend objects.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a potential conflict between the Cozystack provisioner and MetalLB's automatic allocation. If a single IPAddressPool covering the entire range is configured in MetalLB (with autoAssign: true by default), MetalLB will automatically allocate any free IP from this pool to standard Services that do not use claims.

Since MetalLB is unaware of unassociated PublicIP reservations, it could allocate a reserved (but currently unattached) IP to a standard Service, leading to IP conflicts or binding failures when the claim owner later attempts to associate it.

To prevent this, the proposal should specify that the MetalLB IPAddressPool managed by the provisioner must have autoAssign: false configured, or clarify how standard Services are prevented from stealing reserved IPs.

Comment on lines +336 to +337
3. A `ValidatingAdmissionPolicy` **rejects any principal but the controller** writing
*any* backend pin annotation (§1.2's list) on a Service.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a ValidatingAdmissionPolicy to reject any principal except the controller from writing the pin annotations is a robust security measure. However, identifying "the controller" securely in a ValidatingAdmissionPolicy can be challenging and brittle if based solely on hardcoded usernames or namespaces.

Consider specifying how the policy will securely identify the controller principal (e.g., by matching a specific ServiceAccount name and namespace, or utilizing a specific SubjectGroup) to ensure it is both secure and maintainable across different deployment environments.

Timofei Larkin (lllamnyp) added a commit to lllamnyp/cozyplane that referenced this pull request Jul 14, 2026
…esses either

Tenet 3 says the CNI does not announce, and increment 3 deleted the announcer. But it
left the ALLOCATOR: cozyplane still picks external addresses out of an ExternalPool
(firstFreeAddress, for both a VPCGateway's NAT identity and every FloatingIP) and
NOTHING ATTRACTS what it allocates. An ExternalPool is a hand-written CIDR list that
nothing routes; the e2e admits it -- 'here we do what a CCM would do and simply
configure it on one' -- and that CCM does not exist. Allocation without attraction is
an address that exists in etcd and nowhere on the wire.

Pinning the address onto a Service does not bridge the gap: MetalLB's autoAssign:false
does NOT reserve, so MetalLB stays the allocator of record and can hand the address
cozyplane chose to some other Service. Two IPAMs, one range, no arbitration. And an LB
implementation only ever announces an address that is the live loadBalancer.ingress of
a Service it allocated -- so an external address can only be attracted if some OBJECT
holds it.

Resolved in north-south.md §9: cozyplane does not allocate either. It consumes a
platform-allocated, platform-attracted PublicIPClaim (cozystack/community#35).

Three consequences, each fixing something currently papered over:
- The  grant moves onto the claim. It must: a bare Service would let anyone who
  can create a Service mint a public address, which is the R10 hole.
- A pod references a CLAIM, not an address. FloatingIP.spec.target is a VPC IP today --
  address-thinking, against tenet 4 -- and a claim reference on the pod makes 'one
  target, one address' structural instead of a controller tiebreak.
- Only a claim can hold an EGRESS identity. A Service is an ingress object;
  VPCGateway.status.natAddress is many-to-one and has no Service to hang on, yet still
  needs attracting because the replies come back to it. The VPC case is what proves the
  claim object has to exist.

FloatingIP as a kind is likely superseded by a pod annotation naming a claim.

The eBPF does not change at all -- the maps key on an address that arrives from
somewhere and never cared who picked it, which is the strongest evidence the boundary
was drawn in the right place. Answers north-south.md §7's standing questions on who
attracts an EIP and whether its ingress half survives.

Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
@mattia-eleuteri

Copy link
Copy Markdown

A few reactions grounded in a production Cozystack deployment that is squarely the
world this proposal has to serve: MetalLB-only for LoadBalancer allocation
(Cilium LB-IPAM unused), a single shared, auto-assign pool spanning all
tenants, and cozy-proxy whole-IP 1:1 NAT as the datapath for tenant VMs.

Strong +1 on the shape, and especially on the security move as a standalone win

The PV/PVC/StorageClass framing is the right altitude, and the Security section
describes a hole that is live in our cluster today: our public pool is
auto-assign and cross-tenant, so anyone who can create a Service can write
metallb.io/loadBalancerIPs: <any address in the pool> and be handed an address
another tenant is relying on. The ValidatingAdmissionPolicy that forbids any
principal but the controller from writing a pin annotation is worth landing on
its own
, independent of the rest of the model. Please keep it in phase 1 as you
say. One ask: the VAP must explicitly allowlist the platform's own controllers and
the GitOps reconcilers — in our cluster the system istio Services already carry
pool annotations written by non-tenant principals, and a naive policy would break
reconciliation.

The "regression, not a wishlist" framing overstates it for at least one adopter

kube-ovn is installed for us and the OvnEip/IptablesEip CRDs exist, but we run
zero OvnEip instances — the reserve-then-associate capability was never
actually adopted; the 1:1 datapath goes through cozy-proxy instead. So for us this
is a new capability, not one being lost. The motivation stands on its own
merits (hold / move / quota / enumerate / bill); I'd lean less on the regression
argument, since for a real deployment it isn't one.

Suggested additional open question: multiple public addresses per workload

Fine to defer the design, but I'd like it captured explicitly, because it
interacts with the allocation/datapath split. A workload may want more than one
public address (1 v4 + 1 v6, or 2 v4), and the three cases are not the same:

  1. Dual-stack (v4 + v6): clean — one dual-stack Service, two PublicIPs, one
    claim. This is essentially open question 5.
  2. Two same-family, ingress-only (DNAT N→1): both addresses DNAT to the same
    backend, egress via the primary. The secondaries are entry points, not
    owned addresses.
  3. Two same-family, each with full 1:1 whole-IP semantics: the hard one.
    Whole-IP is inherently 1 public ↔ 1 internal address, so N owned addresses
    require N internal addresses on the workload — bringing up an interface (or
    address) per claim, plus guest-side config the platform can't perform
    (cloud-init/DHCP inside the guest). This lands squarely in the deferred datapath
    half.

Two API observations that this open question should keep in mind:

  • Association is currently 1 claim ↔ 1 Service (the annotation names a single
    claim; "two Services reference one claim → reject"). Expressing "this VM holds N
    addresses" would need a workload to reference a set of claims. Open questions
    5 and 7 already circle this — they might collapse into one "a workload may bind N
    claims" question.
  • The Pin "reject rather than half-support" principle should probably extend to
    this: a backend/datapath that can't attach N owned addresses to one workload
    should be able to say so (a declared capability), so a class refuses such a
    claim rather than reserving an address it can never land.

None of this blocks the core question — "is an address a resource?" is a clear yes,
and the single-address path is well worth landing first. Just flagging the
multi-attach case as an open question so the allocation/datapath boundary is drawn
with it in mind rather than around it.

Rework the stub in response to review and maintainer steers:

- Rename the kinds PublicIP/PublicIPClaim/PublicIPClass -> IPAddress/
  IPAddressClaim/IPAddressClass, in the ipam.cozystack.io group. A class names a
  pool, and pools are not necessarily public; the resource is the general address.
- Drop the "regression, not a wishlist" framing entirely. Cozystack uses kube-ovn
  only for subnets and VPCs, never its EIP objects, so there is no prior
  reserve-then-associate capability to preserve. Argue on merits: hold, move,
  quota, enumerate, bill.
- Positioning: standalone-valuable AND part of the Cozystack API surface, both --
  precedent etcd-operator.cozystack.io. Designed for clean Cozystack integration
  without hard-depending on Cozystack machinery.
- Rework Security and the worked examples into two honest layers rather than a
  static invariant. autoAssign:false only stops automatic selection; a Service can
  still name the pool (metallb.io/address-pool) or pin an IP, and a cluster-admin
  can impersonate the controller SA past any identity-keyed admission policy. Layer
  1 (admission) reduces the blast radius for tenants; layer 2 (reconciliation)
  turns any collision -- including impersonation -- into a detected IPAddress
  Conflict rather than silent theft. The cluster-admin/impersonation case is stated
  as a trust boundary, not handwaved. The VAP also covers pool-select annotations,
  identifies the controller by ServiceAccount, and allowlists GitOps/system
  principals so reconciliation is not broken.
- Add a section on allocator-first allocation: MetalLB assigns an address the
  moment it sees a Service, before any claim exists, so the controller must also
  reconcile Service -> inventory after the fact. Commit only to eventual
  consistency; note the volumeClaimTemplate analogy is weak because an IP is
  non-fungible and the allocator does not wait for our controller.
- Status schema is a list (dual-stack), addressing the scalar-field gap.
- Defer multiple platform-owned addresses per workload as out of scope, with a note
  that the case may not be real (a router/VPN VM's extra addresses live on a tunnel
  the platform neither owns nor sees).
- Add a Naming note on the IPAddress overlap with core (KEP-1880) and Cluster API
  IPAM.

Assisted-By: Claude Opus 4.8
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
@lllamnyp

Timofei Larkin (lllamnyp) commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

Thanks — this is the production grounding the proposal needed, and all three points landed. The revision just pushed reflects them.

VAP allowlist — agreed, and it's now explicit, but admission is only half of it. You're right that a naive "reject everyone but the controller" breaks reconciliation the moment a GitOps reconciler or a system Service writes a pool annotation. The policy now identifies the controller by its ServiceAccount (configured at install, not a hardcoded username) and denies the write only for principals outside an explicit, admin-configurable allowlist (controller SA + GitOps/system SAs and groups). It also covers the pool-select annotation (metallb.io/address-pool), not just the IP pin — naming the reserved pool is as dangerous as pinning an address.

But identity-keyed admission has a ceiling: anyone who can impersonate an allowlisted ServiceAccount — a cluster-admin can — sails past it. Rather than pretend otherwise, the design now stands on two layers. Layer 1 is that admission policy, which reduces the blast radius for tenants and namespaced principals — and it's still worth landing on its own, since the theft window it closes is live in any cluster running a shared auto-assign pool. Layer 2 is reconciliation: the IPAddress inventory is authoritative, so any collision — an impersonated write, a direct MetalLB edit, a misconfigured pool — surfaces as IPAddress.status.phase: Conflict with the offending Service named, and the rightful reservation is never silently overwritten. The cluster-admin case is stated as a trust boundary (they're the cluster's trust root; handing out a reserved address is within their authority, not an escalation), handled by detection rather than a prevention promise the model can't keep.

Regression framing — dropped entirely, and further than "for you it's new." In Cozystack it isn't a regression at all: the platform uses kube-ovn only for subnets and VPCs, never its EIP objects, so there's no reserve-then-associate capability being lost. Anyone driving OvnEip deliberately is doing so outside Cozystack. The proposal now argues purely on merits — hold, move, quota, enumerate, bill.

Multiple addresses per workload — captured, but as explicitly deferred scope, not part of the core model. It's a step beyond "an address is a resource," and it's not clear the platform-owned case is even real. The hard sub-case you name — N owned same-family addresses, each needing its own internal address plus in-guest config the platform can't perform — is the one that looks orthogonal to reservation rather than an extension of it, since those extra addresses tend to live on interfaces the platform neither owns nor sees. So the model keeps 1 claim ↔ 1 Service, and the multi-attach case waits for a concrete platform-owned use case to shape it. Dual-stack (one v4 + one v6) is the one narrow multiplicity the model does admit — the claim status is a list from the start — but that's kept separate from the harder "N owned addresses" case.

Two changes worth flagging for your setup specifically. The kinds are renamed IPAddress/IPAddressClaim/IPAddressClass in ipam.cozystack.io — a class names a pool, and pools aren't necessarily public. And because you run MetalLB with a shared auto-assign pool, note that autoAssign: false on a reserved pool only stops automatic selection (a Service can still name the pool or pin an address), so the proposal adds an allocator-first reconciliation path: MetalLB assigns the moment it sees a Service, before any claim exists, and the controller reconciles those addresses back into the inventory toward eventual consistency rather than assuming every address began life as a claim. One honest gap comes with that: an eager allocator can still hand a plain Service an address that is already reserved-but-unattached, which reconciliation detects but cannot cleanly undo — a real fix likely waits for the per-provider provisioners that own allocation, and it's flagged in the doc as deferred to implementation.

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