Skip to content

Reconsider network.cozystack.io (ExposureClass / ServiceExposure): necessity vs native loadBalancerClass, and coordination with the tenant-networking surface #3164

Description

Summary

Two tenant-facing networking API surfaces landed within two hours of each other on 2026-06-30:

They cover two facets of one domain — how a managed application is reachable and who may reach it. This issue raises two questions about the first one:

  1. Necessity — does network.cozystack.io need to exist as a new API group, given that the vendor-neutral LoadBalancer selection it provides is already a native Kubernetes feature (Service.spec.loadBalancerClass)?
  2. Justification for two groups — two API groups for one domain is a fault unless the split is explicitly justified. Plausible arguments exist (serving authority, backing store), but none has been written down, and the exposure surface skipped the design process the policy surface went through.

Background (facts)

network.cozystack.io (#3081):

  • ExposureClass (cluster-scoped, admin): spec.backend ∈ {externalIPs, metallb, cilium, robotlb} + spec.addresses (VIP scope) + L2 settings. StorageClass/IngressClass pattern.
  • ServiceExposure (namespaced, tenant): spec.serviceRef + spec.exposureClassName; status reports resolvedBackend + assignedIPs.
  • The controller resolves ServiceExposure → ExposureClass → backend and renders one pool+announcer per class (PoolName = "cozystack-" + className, internal/controller/serviceexposure/backend/backend.go:125). It never mutates the target Service; the externalIPs and robotlb backends render nothing (Desired() returns nil — backend/externalips.go:43, backend/robotlb.go:40) and only read status.loadBalancer.ingress back.

sdn.cozystack.io (#2922):

  • SecurityGroup (namespaced, tenant): attaches to a managed app via spec.targetRef {apiGroup, kind, name}, endpointSelector derived from the app's lineage labels, projected 1:1 onto a CiliumNetworkPolicy.
  • Served by aggregation: packages/system/cozystack-api/templates/apiservice.yaml:36 registers v1alpha1.sdn.cozystack.io for the whole group/version; pkg/apiserver/apiserver.go:238 implements securitygroups today.

Concern 1 — is ExposureClass / ServiceExposure needed?

The chain that produced #3081 (#2468 → reverted by #2603 → #3081) is about migrating the host ingress off the deprecated Service.spec.externalIPs (KEP-5707), with a vendor-neutral choice of LoadBalancer backend. Two clarifications:

  • The deprecation is scoped to the host-ingress node-IP path, not managed-application exposure. publishing.externalIPs are the nodes' own public IPs (install docs, "Node Public IP Setup"); managed apps that set external: true already render a plain type: LoadBalancer Service (e.g. packages/apps/postgres/templates/external-svc.yaml:7) and are unaffected by KEP-5707. The migration urgency does not transfer to app exposure.
  • Vendor-neutral LoadBalancer backend selection is already native. Service.spec.loadBalancerClass selects which controller handles a type: LoadBalancer Service and is honored by MetalLB (--lb-class, wired in-tree at packages/system/metallb/charts/metallb/templates/controller.yaml), Cilium, and cloud LB controllers. Every backend in feat(network): mechanism-agnostic LoadBalancer exposure (ExposureClass + ServiceExposure) #3081 already relies on the Service being type: LoadBalancer (and externalIPs/robotlb render nothing), so type: LoadBalancer + loadBalancerClass + an admin-provisioned pool per class covers the same vendor-neutral goal with no new API group — the allocated IP is already reported natively at Service.status.loadBalancer.ingress.

On that basis:

  • ExposureClass is defensible as an admin-owned, named config object (the StorageClass analogy holds).
  • ServiceExposure substantially overlaps native mechanisms, and its controller's own work is a single per-class pool. The open question: what does ServiceExposure provide that Service.spec.loadBalancerClass + an admin-provisioned pool per class does not? If the answer is thin, it is worth reducing before more is built on it — a pending design proposal (community [docs] How to interconnect tenant with external systems via VXLAN #29) puts managed-application exposure on top of ServiceExposure, so this is the moment to settle it.

Concern 2 — two API groups for one domain need a justification that has not been written

Exposure ("what is published") and security-group ("who may connect") are two facets of one tenant-networking model, and they diverge on every structural axis:

network.cozystack.io (#3081) sdn.cozystack.io (#2922)
Serving stored CRDs + reconciling controller aggregated-apiserver projection
Backing store cluster etcd (CRDs) none today (virtual); persistent store if it grows
Attaches to app via serviceRef (a Service) targetRef + derived selector (the app's pods)
Status reconciled assignedIPs / resolvedBackend native CiliumNetworkPolicy round-trip

Two API groups covering adjacent facets of one domain is a defect by default: it fragments a single tenant-facing concern across two surfaces, doubles what a tenant or UI must learn, and splits ownership. The burden is on justifying why both must exist — and that case has to be made explicitly, not assumed. Several arguments plausibly support a split, and at least one is a hard technical forcing function, but someone has to articulate and weigh them:

  • Serving authority (strong). A group/version is served by exactly one authority — either CRDs via apiextensions, or a single aggregated APIService (v1alpha1.sdn.cozystack.io claims the whole GV). SecurityGroup is served by aggregation (server-side validation and projection today; atomic server-side IPAM and custom verbs as the surface grows); ExposureClass/ServiceExposure are plain CRDs. Those genuinely cannot share one group/version. But that equally argues the exposure surface could have matched the policy surface's serving model — which of the two is correct is exactly the analysis that was skipped.
  • Backing store. CRDs persist in the cluster's main etcd; an aggregated apiserver serving non-virtual resources needs its own storage. Grouping by persistence substrate is defensible.
  • Lifecycle / ownership. Distinct controllers, release cadence, and RBAC scoping can argue for separation.

None of these has been written down or weighed against the cost of fragmentation. sdn.cozystack.io came from a written design (#1614) and review; network.cozystack.io was introduced in an implementation PR with a revert body (#2603) as its brief. Absent an articulated, on-the-record case for two groups — and where the boundary between them sits — the default answer is one coherent tenant-networking surface. So: either someone makes that case, or exposure and policy should be consolidated. This should be settled before community #29 extends the exposure half.

Proposed resolution

  1. Settle ServiceExposure's necessity. Either document what it provides over Service.spec.loadBalancerClass + an admin-provisioned pool per class, or plan its reduction. Resolve before community [docs] How to interconnect tenant with external systems via VXLAN #29 depends on it.
  2. Justify the split or consolidate. Require an explicit, recorded rationale for why exposure and policy must live in separate API groups — weighing the serving-authority and backing-store arguments against the cost of fragmentation — and where the boundary sits. If no sufficient case is made, consolidate onto one tenant-networking surface.

References

Activity

  1. kvaps commented on Jul 6, 2026

    @kvaps
    Member

    Thanks for writing this up — the process point is fair: network.cozystack.io landed through an implementation PR (the #2468 → #2603 → #3081 chain) without the design round sdn.cozystack.io got.

    I dug through the #3081 controller for the managed-application case specifically (where the chart owns the Service), and I've come around: for that case ServiceExposure is largely redundant.

    What the controller actually does per backend:

    • externalIPs and robotlb render nothing (Desired returns nil — backend/externalips.go:39-44, backend/robotlb.go:36-41); they only mirror status.loadBalancer.ingress back.
    • metallb/cilium render exactly one pool + one announcer per class (backend/backend.go:124-126).
    • It never mutates the Service (reconciler.go:392 is a Watches, not Owns) — the chart owns the Service's type/externalIPs.

    The node-IP default doesn't go through this at all: publishing.externalIPs is set as Service.spec.externalIPs on a ClusterIP Service at the chart level, and the externalIPs backend is a no-op there. (I over-stated this earlier — a MetalLB pool with no L2Advertisement also expresses "allocate but don't announce", so that isn't a ServiceExposure-only capability.)

    The one thing ServiceExposure uniquely buys is dynamic, tenant-driven per-namespace scoping of a shared VIP pool (the namespace set in serviceAllocation/serviceSelector is recomputed each reconcile — reconciler.go:174-225) plus mechanism-agnostic class → backend indirection. Everything else in the controller (conflict/GC/finalizer/status) exists only to manage that dynamic pool.

    For managed apps none of that is required: the chart renders type: LoadBalancer (+ optional loadBalancerClass) or spec.externalIPs, the IP comes back natively in status.loadBalancer.ingress, and a one-time admin-provisioned pool (per class, or cluster-wide) replaces the dynamic rendering. loadBalancerClass is the clean way to keep the default LB controller from grabbing the Service (MetalLB supports --lb-class; we just don't wire it today).

    So I'd revise community#29 to target native type: LoadBalancer + loadBalancerClass + an admin pool rather than ServiceExposure. The expose field shape is unchanged — class just maps to a loadBalancerClass / admin pool instead of an exposureClassName.

    On the two API groups: that settles the managed-app path — managed-app exposure needs no new tenant-facing group, so sdn.cozystack.io stays as the tenant policy surface and network.cozystack.io narrows to what it's actually good at (exposing an arbitrary/non-managed Service — its current host-ingress consumer) or gets folded in. Whether that narrower niche stays or is consolidated is worth deciding here / at a sync.

    community#29 stays draft until we settle this.

  2. added
    kind/cleanupCategorizes issue or PR as related to cleanup of code, process, or technical debt
    area/networkingIssues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn)
    triage/not-reproducibleIndicates an issue can not be reproduced as described
    and removed
    triage/needs-triageIndicates an issue needs triage by a maintainer
    on Sep 23, 2026
  3. lexfrei commented on Sep 23, 2026

    @lexfrei
    Contributor

    Resolved by #3218, which dropped ExposureClass/ServiceExposure for native loadBalancerClass. network.cozystack.io doesn't exist on main anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/networkingIssues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn)kind/cleanupCategorizes issue or PR as related to cleanup of code, process, or technical debttriage/not-reproducibleIndicates an issue can not be reproduced as described

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions