Repository navigation
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:
network.cozystack.io/v1alpha1—ExposureClass+ServiceExposure(feat(network): mechanism-agnostic LoadBalancer exposure (ExposureClass + ServiceExposure) #3081), CRDs reconciled by a controller incozystack-controller.sdn.cozystack.io/v1alpha1—SecurityGroup(feat(api): add SecurityGroup network policy resource (sdn.cozystack.io) #2922, from design [securitygroups] WIP #1614), served by thecozystack-apiaggregated apiserver as a projection ontoCiliumNetworkPolicy.
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:
- Necessity — does
network.cozystack.ioneed 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)? - 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 reportsresolvedBackend+assignedIPs.- The controller resolves
ServiceExposure → ExposureClass → backendand renders one pool+announcer per class (PoolName = "cozystack-" + className,internal/controller/serviceexposure/backend/backend.go:125). It never mutates the target Service; theexternalIPsandrobotlbbackends render nothing (Desired()returnsnil—backend/externalips.go:43,backend/robotlb.go:40) and only readstatus.loadBalancer.ingressback.
sdn.cozystack.io (#2922):
SecurityGroup(namespaced, tenant): attaches to a managed app viaspec.targetRef {apiGroup, kind, name},endpointSelectorderived from the app's lineage labels, projected 1:1 onto aCiliumNetworkPolicy.- Served by aggregation:
packages/system/cozystack-api/templates/apiservice.yaml:36registersv1alpha1.sdn.cozystack.iofor the whole group/version;pkg/apiserver/apiserver.go:238implementssecuritygroupstoday.
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.externalIPsare the nodes' own public IPs (install docs, "Node Public IP Setup"); managed apps that setexternal: truealready render a plaintype: LoadBalancerService (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.loadBalancerClassselects which controller handles atype: LoadBalancerService and is honored by MetalLB (--lb-class, wired in-tree atpackages/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 beingtype: LoadBalancer(andexternalIPs/robotlbrender nothing), sotype: 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 atService.status.loadBalancer.ingress.
On that basis:
ExposureClassis defensible as an admin-owned, named config object (the StorageClass analogy holds).ServiceExposuresubstantially overlaps native mechanisms, and its controller's own work is a single per-class pool. The open question: what doesServiceExposureprovide thatService.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 ofServiceExposure, 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.ioclaims the whole GV).SecurityGroupis served by aggregation (server-side validation and projection today; atomic server-side IPAM and custom verbs as the surface grows);ExposureClass/ServiceExposureare 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
- Settle
ServiceExposure's necessity. Either document what it provides overService.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. - 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
- feat(network): mechanism-agnostic LoadBalancer exposure (ExposureClass + ServiceExposure) #3081 —
network.cozystack.io(ExposureClass,ServiceExposure) - feat(api): add SecurityGroup network policy resource (sdn.cozystack.io) #2922 —
sdn.cozystack.io(SecurityGroup), from design [securitygroups] WIP #1614 - feat(ingress): add loadBalancer exposure mode via CiliumLoadBalancerIPPool #2468 (loadBalancer exposure mode) → revert(ingress)!: remove broken publishing.exposure=loadBalancer mode #2603 (revert) — the migration history behind feat(network): mechanism-agnostic LoadBalancer exposure (ExposureClass + ServiceExposure) #3081
- KEP-5707 — deprecation/removal of
Service.spec.externalIPs - Kubernetes
Service.spec.loadBalancerClass— native vendor-neutral LoadBalancer backend selection - community [docs] How to interconnect tenant with external systems via VXLAN #29 — pending proposal building managed-app exposure on
ServiceExposure
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
kvaps commentedon Jul 6, 2026
Thanks for writing this up — the process point is fair:
network.cozystack.iolanded through an implementation PR (the #2468 → #2603 → #3081 chain) without the design roundsdn.cozystack.iogot.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
ServiceExposureis largely redundant.What the controller actually does per backend:
externalIPsandrobotlbrender nothing (Desiredreturns nil —backend/externalips.go:39-44,backend/robotlb.go:36-41); they only mirrorstatus.loadBalancer.ingressback.metallb/ciliumrender exactly one pool + one announcer per class (backend/backend.go:124-126).reconciler.go:392is aWatches, notOwns) — the chart owns the Service's type/externalIPs.The node-IP default doesn't go through this at all:
publishing.externalIPsis set asService.spec.externalIPson aClusterIPService at the chart level, and theexternalIPsbackend is a no-op there. (I over-stated this earlier — a MetalLB pool with noL2Advertisementalso expresses "allocate but don't announce", so that isn't aServiceExposure-only capability.)The one thing
ServiceExposureuniquely buys is dynamic, tenant-driven per-namespace scoping of a shared VIP pool (the namespace set inserviceAllocation/serviceSelectoris recomputed each reconcile —reconciler.go:174-225) plus mechanism-agnosticclass → backendindirection. 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(+ optionalloadBalancerClass) orspec.externalIPs, the IP comes back natively instatus.loadBalancer.ingress, and a one-time admin-provisioned pool (per class, or cluster-wide) replaces the dynamic rendering.loadBalancerClassis 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 thanServiceExposure. Theexposefield shape is unchanged —classjust maps to aloadBalancerClass/ admin pool instead of anexposureClassName.On the two API groups: that settles the managed-app path — managed-app exposure needs no new tenant-facing group, so
sdn.cozystack.iostays as the tenant policy surface andnetwork.cozystack.ionarrows 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.
refactor(network): drop ExposureClass/ServiceExposure for native load…
refactor(network): drop ExposureClass/ServiceExposure for native load…
lexfrei commentedon Sep 23, 2026
Resolved by #3218, which dropped
ExposureClass/ServiceExposurefor nativeloadBalancerClass.network.cozystack.iodoesn't exist on main anymore.