Repository navigation
Reconsider network.cozystack.io (ExposureClass / ServiceExposure): necessity vs native loadBalancerClass, and coordination with the tenant-networking surface #3164
Description
Activity
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).- It never mutates the Service (
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.
- added a commit that references this issue
on Jul 7, 2026 - added a commit that references this issue
on Jul 16, 2026 - addedtriage/needs-triageIndicates an issue needs triage by a maintainerIndicates an issue needs triage by a maintainer
on Aug 12, 2026 - addedkind/cleanupCategorizes issue or PR as related to cleanup of code, process, or technical debtCategorizes issue or PR as related to cleanup of code, process, or technical debtarea/networkingIssues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn)Issues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn)triage/not-reproducibleIndicates an issue can not be reproduced as describedIndicates an issue can not be reproduced as describedand removedtriage/needs-triageIndicates an issue needs triage by a maintainerIndicates an issue needs triage by a maintainer
on Sep 23, 2026 Resolved by #3218, which dropped
ExposureClass/ServiceExposurefor nativeloadBalancerClass.network.cozystack.iodoesn't exist on main anymore.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
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:
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)?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.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.packages/system/cozystack-api/templates/apiservice.yaml:36registersv1alpha1.sdn.cozystack.iofor the whole group/version;pkg/apiserver/apiserver.go:238implementssecuritygroupstoday.Concern 1 — is
ExposureClass/ServiceExposureneeded?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: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.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)serviceRef(a Service)targetRef+ derived selector (the app's pods)assignedIPs/resolvedBackendCiliumNetworkPolicyround-tripTwo 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:
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.None of these has been written down or weighed against the cost of fragmentation.
sdn.cozystack.iocame from a written design (#1614) and review;network.cozystack.iowas 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
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.References
network.cozystack.io(ExposureClass,ServiceExposure)sdn.cozystack.io(SecurityGroup), from design [securitygroups] WIP #1614Service.spec.externalIPsService.spec.loadBalancerClass— native vendor-neutral LoadBalancer backend selectionServiceExposure