Skip to content

feat(network): mechanism-agnostic LoadBalancer exposure (ExposureClass + ServiceExposure) - #3081

Merged
Aleksei Sviridkin (lexfrei) merged 4 commits into
mainfrom
feat/serviceexposure
Jun 30, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 4 commits into
mainfrom
feat/serviceexposure

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Lays the foundation for migrating tenant LoadBalancer exposure off the deprecated Service.spec.externalIPs (KEP-5707) and, eventually, off MetalLB onto Cilium-native announcement — the mechanism-agnostic shape prescribed when the earlier chart-side approach (#2468) was reverted in #2603.

It adds a new network.cozystack.io/v1alpha1 API group and a controller that translates intent into whichever LoadBalancer backend the cluster admin configured:

  • ExposureClass (cluster-scoped, admin): binds a logical class name to a backend (externalIPs | metallb | cilium | robotlb) and its VIP address scope — the StorageClass/IngressClass pattern.
  • ServiceExposure (namespaced, tenant intent): "expose this Service via class X"; status reports the resolved backend and assigned IPs.
  • A serviceexposure controller renders the backend's pool + announcer once per class (MetalLB IPAddressPool + L2Advertisement; Cilium CiliumLoadBalancerIPPool + CiliumL2AnnouncementPolicy), scoped to the namespaces that opted in. Backends are pluggable behind a small Go interface, so adding BGP or another cloud LB is one implementation, not an API change. The controller never mutates the target Service — the chart still owns its shape.

Platform wiring: publishing.exposureClass.* renders the admin ExposureClass and the host ingress publishes through it. The default (no class set, externalIPs) leaves every existing cluster's behaviour byte-for-byte unchanged; the MetalLB→Cilium announcer migration becomes a single ExposureClass.spec.backend change.

This is the foundation only — Part of #2586. Converting per-app exposure, flipping Cilium l2announcements on, changing the default, and removing MetalLB are deferred to later phases.

Release note

feat(network): add ExposureClass and ServiceExposure for mechanism-agnostic LoadBalancer exposure. Opt-in foundation; the default externalIPs publishing path is unchanged.

Summary by CodeRabbit

  • New Features
    • Added new ExposureClass and ServiceExposure custom resources with support for multiple backends (externalIPs, MetalLB, Cilium, RobotLB), including optional L2 and address configuration.
    • Added Helm wiring and templates to enable service/ingress publishing via ServiceExposure, selecting a configured (or default) ExposureClass.
  • Bug Fixes
    • Improved reconciliation, finalizer-based cleanup, and status updates for readiness and assigned IP reporting, including safer handling when backends or managed resources change.
  • Tests
    • Added end-to-end and unit test coverage for backend rendering, readiness, and cleanup flows.

Introduce a new network.cozystack.io/v1alpha1 API group with two kinds
that form the mechanism-agnostic LoadBalancer exposure model:

- ExposureClass (cluster-scoped): the admin binds a logical class to a
  concrete backend (externalIPs, metallb, cilium, robotlb) and its VIP
  address scope, following the StorageClass / IngressClass pattern.
- ServiceExposure (namespaced): the tenant-facing intent to expose a
  Service externally via a named class, with status reporting the
  resolved backend and assigned IPs.

Wire api/network into the controller-gen CRD generation so deepcopy and
the two CRD manifests are produced alongside the existing groups.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
Add the serviceexposure controller to the cozystack-controller binary. It
resolves a ServiceExposure's ExposureClass to a backend and renders the
backend-specific pool and announcer CRs, then reports the assigned IPs in
status.

Backends are pluggable behind a small interface, one per file:

- externalIPs: renders nothing (the node owns the address) — preserves the
  historical default behaviour.
- metallb: IPAddressPool + L2Advertisement in cozy-metallb, scoped to the
  target Service's namespace.
- cilium: CiliumLoadBalancerIPPool (cilium.io/v2) + CiliumL2AnnouncementPolicy
  (cilium.io/v2alpha1), scoped to the Service via serviceSelector.
- robotlb: renders nothing (the cloud LB owns allocation and announcement).

The rendered CRs belong to foreign API groups not in the Go scheme, so they
are emitted as unstructured objects. They carry no OwnerReference (the
Cilium CRs are cluster-scoped and the MetalLB CRs live in a different
namespace than the owning ServiceExposure), so ownership is tracked via a
management label plus owner annotations, cleanup is driven by a finalizer,
and the controller refuses to take over an identically-named resource it
does not manage. The controller never mutates the target Service, leaving
its shape to the chart that renders it.

Grant the controller write access to metallb.io and cilium.io pool /
announcer resources.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
Add the operator-facing knob that drives the new exposure model. When
publishing.exposureClass.name is set, the platform renders a cluster-scoped
ExposureClass binding the chosen backend (externalIPs, metallb, cilium,
robotlb) and VIP address scope, and carries the class name through the
cozystack-values Secret. The host ingress then renders a ServiceExposure
for its ingress-nginx Service, which the cozystack-controller translates
into the backend's pool and announcer.

The default (empty name) renders neither the ExposureClass nor the
ServiceExposure, so clusters on the externalIPs path are unchanged on
upgrade. The ingress Service shape is untouched: it is already published
as type LoadBalancer whenever externalIPs is empty.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
@lexfrei Aleksei Sviridkin (lexfrei) added the area/networking Issues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn) label Jun 25, 2026
@github-actions github-actions Bot added area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files labels Jun 25, 2026
@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 21ec0dc8-9721-4e33-8379-2e647189c881

📥 Commits

Reviewing files that changed from the base of the PR and between 9d2d687 and 511f2a3.

📒 Files selected for processing (1)
  • hack/e2e-apps/serviceexposure.bats
🚧 Files skipped from review as they are similar to previous changes (1)
  • hack/e2e-apps/serviceexposure.bats

📝 Walkthrough

Walkthrough

Adds network.cozystack.io v1alpha1 ExposureClass and ServiceExposure APIs, backend implementations for externalIPs/MetalLB/Cilium/RobotLB, controller reconciliation and mapping, Helm wiring, CRDs, RBAC, and validation tests.

Changes

Service exposure control flow

Layer / File(s) Summary
API contracts and CRDs
api/network/v1alpha1/*, packages/system/cozystack-controller/definitions/network.cozystack.io_*.yaml, hack/update-codegen.sh
Defines the v1alpha1 API group, ExposureClass and ServiceExposure types, deepcopy support, generated CRDs, and codegen paths for the new network API.
Helm exposure-class wiring
packages/core/platform/{values.yaml,templates/apps.yaml,templates/exposureclass.yaml,tests/*.yaml}, packages/extra/ingress/{templates/serviceexposure.yaml,tests/serviceexposure_test.yaml}
Adds exposure-class values and templates, renders ExposureClass and ServiceExposure objects from chart values, and adds template tests for default, override, and validation cases.
Backend contract and implementations
internal/controller/serviceexposure/backend/*
Defines the backend registry and interface, implements externalIPs, MetalLB, Cilium, and RobotLB backends, and adds unit tests for rendering and Service observation.
Mapper, status, and ownership helpers
internal/controller/serviceexposure/{conflict.go,mapper.go,mapper_test.go,status.go}, packages/system/cozystack-controller/templates/rbac.yaml
Adds ownership helpers, status reconciliation helpers, Service/ExposureClass mapping logic, and RBAC for backend resources.
Controller runtime and manager wiring
internal/controller/serviceexposure/reconciler.go, internal/controller/serviceexposure/classreconciler.go, cmd/cozystack-controller/main.go
Implements ServiceExposure and ExposureClass reconciliation, backend object application and garbage collection, controller setup, and main registration.
Controller validation
internal/controller/serviceexposure/reconciler_test.go
Adds reconcile tests covering missing refs, backend switching, conflicts, deletion paths, CRD-missing errors, idempotency, and class cleanup behavior.
E2E ServiceExposure flow
hack/e2e-apps/serviceexposure.bats
Adds a live-cluster Bats flow that creates an ExposureClass, Service, and ServiceExposure, verifies externalIPs reconciliation, and checks deletion cleanup.

Sequence Diagram(s)

sequenceDiagram
  participant ServiceExposure
  participant Reconciler
  participant BackendRegistry
  participant Backend
  participant Service
  participant ServiceExposureStatus
  ServiceExposure->>Reconciler: Reconcile(req)
  Reconciler->>BackendRegistry: For(class.Spec.Backend)
  BackendRegistry-->>Reconciler: Backend
  Reconciler->>Backend: Desired(class, namespaces)
  Backend-->>Reconciler: desired objects
  Reconciler->>Service: Get(serviceRef)
  Reconciler->>Backend: Observe(Service)
  Backend-->>Reconciler: assignedIPs, ready, reason
  Reconciler->>ServiceExposureStatus: markReady or markFailed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

area/api, area/platform

Suggested reviewers

  • kvaps
  • lllamnyp
  • androndo
  • IvanHunters
  • sircthulhu

Poem

A bunny hops through pools of light,
New classes bloom in network night.
With twitches, tests, and carrot cheer,
The load balancer path grows clear. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: introducing mechanism-agnostic LoadBalancer exposure via ExposureClass and ServiceExposure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/serviceexposure

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.

@dosubot dosubot Bot added the kind/api-change Categorizes issue or PR as related to adding, removing, or otherwise changing an API label Jun 25, 2026
@lexfrei Aleksei Sviridkin (lexfrei) removed the area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review label Jun 25, 2026
@github-actions github-actions Bot added the area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review label Jun 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
hack/e2e-apps/serviceexposure.bats (1)

129-131: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the redundant inline fixture cleanup.

teardown() (Lines 32-34) already deletes the service and exposureclass after every test, so these inline deletes are redundant. The deletion test should assert the ServiceExposure removal (Lines 126-127) and leave fixture teardown to the framework.

♻️ Proposed cleanup
   kubectl -n tenant-test delete serviceexposure "$EXP" --timeout=2m
   kubectl -n tenant-test wait serviceexposure/"$EXP" --for=delete --timeout=1m --ignore-not-found
-
-  # Inline cleanup of the remaining fixtures.
-  kubectl -n tenant-test delete service "$SVC" --ignore-not-found --timeout=1m
-  kubectl delete exposureclass "$CLASS" --ignore-not-found --timeout=1m
 }
Based on learnings: replace inline cleanup in E2E bats tests with framework-level teardown rather than per-test cleanup.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/e2e-apps/serviceexposure.bats` around lines 129 - 131, Remove the
redundant inline fixture cleanup from the E2E test body in serviceexposure.bats:
the test should only assert the ServiceExposure removal and rely on the existing
teardown() helper to delete the Service and exposureclass fixtures after each
test. Keep the cleanup logic centralized in teardown() and delete the explicit
kubectl delete calls from the deletion test block, preserving the
ServiceExposure assertion and other test flow unchanged.

Source: Learnings

internal/controller/serviceexposure/status.go (1)

47-52: 📐 Maintainability & Code Quality | 🔵 Trivial

Replace direct type assertion with errors.As

The type assertion err.(*reconcileError) is fragile. Although failf currently returns errors directly, any future wrapping via fmt.Errorf("...: %w") would cause reasonOf to lose the specific reason and fallback to "ReconcileError". Use errors.As to correctly handle wrapped errors and make the code future-proof.

Proposed refactor
 func reasonOf(err error) string {
-	if re, ok := err.(*reconcileError); ok {
+	var re *reconcileError
+	if errors.As(err, &re) {
 		return re.reason
 	}
 	return "ReconcileError"
 }

Ensure "errors" is in the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/serviceexposure/status.go` around lines 47 - 52, Replace
the direct type assertion in reasonOf with errors.As so wrapped reconcile errors
still expose their reason instead of falling back to "ReconcileError". Update
the logic in reasonOf(err error) to unwrap into a reconcileError using
errors.As, and add the errors import in the same file if it is not already
present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/controller/serviceexposure/backend/backend.go`:
- Around line 51-56: The Cilium LB-IPAM version pins in backend.go require a
minimum Cilium 1.16, so update the project documentation to state that this
controller only supports clusters running Cilium 1.16 or newer. Add the
requirement in the main docs/installation notes where compatibility is
described, referencing the CiliumLoadBalancerIPPool and ciliumPoolAPIVersion
behavior so users know cilium.io/v2 is not valid on earlier releases.

In `@packages/core/platform/templates/exposureclass.yaml`:
- Around line 24-27: The exposure class template only emits the default-class
annotation when .isDefault is explicitly set, so omitted values no longer follow
the documented true default. Update the exposureclass.yaml template to treat a
missing isDefault the same as true when rendering the annotations block, using
the existing .isDefault check in this template so values.yaml’s default behavior
is preserved.

---

Nitpick comments:
In `@hack/e2e-apps/serviceexposure.bats`:
- Around line 129-131: Remove the redundant inline fixture cleanup from the E2E
test body in serviceexposure.bats: the test should only assert the
ServiceExposure removal and rely on the existing teardown() helper to delete the
Service and exposureclass fixtures after each test. Keep the cleanup logic
centralized in teardown() and delete the explicit kubectl delete calls from the
deletion test block, preserving the ServiceExposure assertion and other test
flow unchanged.

In `@internal/controller/serviceexposure/status.go`:
- Around line 47-52: Replace the direct type assertion in reasonOf with
errors.As so wrapped reconcile errors still expose their reason instead of
falling back to "ReconcileError". Update the logic in reasonOf(err error) to
unwrap into a reconcileError using errors.As, and add the errors import in the
same file if it is not already present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2859239-9a77-473f-a17b-2be7fb8fbde7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cb1b4c and 9d2d687.

📒 Files selected for processing (31)
  • api/network/v1alpha1/exposureclass_types.go
  • api/network/v1alpha1/groupversion_info.go
  • api/network/v1alpha1/serviceexposure_types.go
  • api/network/v1alpha1/zz_generated.deepcopy.go
  • cmd/cozystack-controller/main.go
  • hack/e2e-apps/serviceexposure.bats
  • hack/update-codegen.sh
  • internal/controller/serviceexposure/backend/backend.go
  • internal/controller/serviceexposure/backend/backend_test.go
  • internal/controller/serviceexposure/backend/cilium.go
  • internal/controller/serviceexposure/backend/externalips.go
  • internal/controller/serviceexposure/backend/metallb.go
  • internal/controller/serviceexposure/backend/registry.go
  • internal/controller/serviceexposure/backend/robotlb.go
  • internal/controller/serviceexposure/classreconciler.go
  • internal/controller/serviceexposure/conflict.go
  • internal/controller/serviceexposure/mapper.go
  • internal/controller/serviceexposure/mapper_test.go
  • internal/controller/serviceexposure/reconciler.go
  • internal/controller/serviceexposure/reconciler_test.go
  • internal/controller/serviceexposure/status.go
  • packages/core/platform/templates/apps.yaml
  • packages/core/platform/templates/exposureclass.yaml
  • packages/core/platform/tests/apps_exposure_class_test.yaml
  • packages/core/platform/tests/exposureclass_wiring_test.yaml
  • packages/core/platform/values.yaml
  • packages/extra/ingress/templates/serviceexposure.yaml
  • packages/extra/ingress/tests/serviceexposure_test.yaml
  • packages/system/cozystack-controller/definitions/network.cozystack.io_exposureclasses.yaml
  • packages/system/cozystack-controller/definitions/network.cozystack.io_serviceexposures.yaml
  • packages/system/cozystack-controller/templates/rbac.yaml

Comment on lines +51 to +56
metallbAPIVersion = "metallb.io/v1beta1"
// CiliumLoadBalancerIPPool graduated to cilium.io/v2 in Cilium 1.16+,
// while CiliumL2AnnouncementPolicy is still served at v2alpha1 — the
// two LB-IPAM CRs use DIFFERENT apiVersions in the same release.
ciliumPoolAPIVersion = "cilium.io/v2"
ciliumL2APIVersion = "cilium.io/v2alpha1"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

What apiVersion does CiliumLoadBalancerIPPool use in Cilium 1.15 versus 1.16, and is CiliumL2AnnouncementPolicy still served at v2alpha1 in Cilium 1.16/1.17?

💡 Result:

In both Cilium 1.15 and 1.16, the CiliumLoadBalancerIPPool resource is served under the apiVersion cilium.io/v2alpha1 [1][2]. While the API version remains the same, note that Cilium 1.16 introduced breaking changes to the specification, such as the removal of the deprecated spec.cidrs field in favor of spec.blocks and a change in the default behavior for spec.allowFirstLastIPs [3][4]. CiliumL2AnnouncementPolicy continues to be served at the v2alpha1 version in Cilium 1.16 and remains at that version in more recent releases (including 1.17 and later) [5][6][7][8].

Citations:


Specify minimum Cilium 1.16 requirement

The code pins CiliumLoadBalancerIPPool to cilium.io/v2, which is only available in Cilium 1.16. In Cilium 1.15 and earlier, this resource exists only as cilium.io/v2alpha1. Deploying this controller on a cluster running Cilium < 1.16 will result in applyObject failures due to group version mismatches. Please update the project documentation to explicitly state a minimum support requirement of Cilium 1.16.

Diff snippet
	metallbAPIVersion = "metallb.io/v1beta1"
	// CiliumLoadBalancerIPPool graduated to cilium.io/v2 in Cilium 1.16+,
	// while CiliumL2AnnouncementPolicy is still served at v2alpha1 — the
	// two LB-IPAM CRs use DIFFERENT apiVersions in the same release.
	ciliumPoolAPIVersion = "cilium.io/v2"
	ciliumL2APIVersion   = "cilium.io/v2alpha1"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/serviceexposure/backend/backend.go` around lines 51 - 56,
The Cilium LB-IPAM version pins in backend.go require a minimum Cilium 1.16, so
update the project documentation to state that this controller only supports
clusters running Cilium 1.16 or newer. Add the requirement in the main
docs/installation notes where compatibility is described, referencing the
CiliumLoadBalancerIPPool and ciliumPoolAPIVersion behavior so users know
cilium.io/v2 is not valid on earlier releases.

Comment on lines +24 to +27
{{- if .isDefault }}
annotations:
exposureclass.network.cozystack.io/is-default-class: "true"
{{- end }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the documented isDefault: true default when the key is omitted.

This branch only adds the default-class annotation when .isDefault is explicitly truthy, so a trimmed values file that sets publishing.exposureClass.name but omits isDefault stops rendering the annotation even though values.yaml documents true as the default. That silently changes how no-class ServiceExposures resolve.

Suggested fix
-  {{- if .isDefault }}
+  {{- if or .isDefault (not (hasKey . "isDefault")) }}
   annotations:
     exposureclass.network.cozystack.io/is-default-class: "true"
   {{- end }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{{- if .isDefault }}
annotations:
exposureclass.network.cozystack.io/is-default-class: "true"
{{- end }}
{{- if or .isDefault (not (hasKey . "isDefault")) }}
annotations:
exposureclass.network.cozystack.io/is-default-class: "true"
{{- end }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/platform/templates/exposureclass.yaml` around lines 24 - 27,
The exposure class template only emits the default-class annotation when
.isDefault is explicitly set, so omitted values no longer follow the documented
true default. Update the exposureclass.yaml template to treat a missing
isDefault the same as true when rendering the annotations block, using the
existing .isDefault check in this template so values.yaml’s default behavior is
preserved.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes a foundation for mechanism-agnostic LoadBalancer exposure within Cozystack. By moving away from deprecated Service.spec.externalIPs and hardcoded MetalLB dependencies, it introduces a flexible abstraction layer where cluster admins can define ExposureClasses and tenants can request exposure via ServiceExposure resources. This architecture enables seamless switching between different network backends like Cilium or cloud-native load balancers without requiring changes to tenant-level service definitions.

Highlights

  • New API Group: Introduced the network.cozystack.io/v1alpha1 API group with ExposureClass and ServiceExposure CRDs to decouple LoadBalancer exposure from specific implementations.
  • Pluggable Backend Controller: Implemented a mechanism-agnostic controller that supports multiple backends including externalIPs, MetalLB, Cilium, and RobotLB.
  • Platform Configuration: Updated platform Helm charts to allow cluster admins to define ExposureClasses and configure host ingress publishing.
  • Testing and Reliability: Added comprehensive E2E smoke tests and unit tests for backend rendering logic to ensure stable LoadBalancer configuration.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Ignored Files
  • Ignored by pattern: **/zz_generated.*.go (1)
    • api/network/v1alpha1/zz_generated.deepcopy.go
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

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

Copy link
Copy Markdown
Contributor

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 mechanism-agnostic LoadBalancer exposure system by adding ExposureClass and ServiceExposure CRDs under the network.cozystack.io API group, along with their respective controllers and backend implementations (MetalLB, Cilium, externalIPs, and RobotLB). The review feedback highlights several actionable improvements: exposing and documenting the 'interfaces' field in the Helm templates and values.yaml, returning early after finalizer updates in the reconciler to avoid redundant work, using the 'backendCRDMissing' helper consistently, adopting 'errors.As' for robust error type assertions, and validating trimmed IP range boundaries to prevent malformed Cilium pool configurations.

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 +31 to +34
{{- with .addresses }}
addresses:
{{- toYaml . | nindent 4 }}
{{- end }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The interfaces field defined in ExposureClassSpec is supported by the controller but is completely omitted from the Helm template. This makes it impossible for operators to configure specific host network interfaces for L2 announcement via the platform chart values. We should render interfaces if specified.

  {{- with .addresses }}
  addresses:
    {{- toYaml . | nindent 4 }}
  {{- end }}
  {{- with .interfaces }}
  interfaces:
    {{- toYaml . | nindent 4 }}
  {{- end }}

Comment on lines +128 to +132
if controllerutil.AddFinalizer(exp, exposureFinalizer) {
if err := r.Update(ctx, exp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When adding a finalizer to the ServiceExposure object, it is a best practice in controller-runtime to return early (e.g., return ctrl.Result{}, nil) after the r.Update call. Falling through to runReconcileSteps in the same run can lead to redundant work and potential API conflict errors if the status is updated concurrently while the apiserver is still processing the metadata update.

Suggested change
if controllerutil.AddFinalizer(exp, exposureFinalizer) {
if err := r.Update(ctx, exp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
}
if controllerutil.AddFinalizer(exp, exposureFinalizer) {
if err := r.Update(ctx, exp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
return ctrl.Result{}, nil
}
References
  1. Go Code Standards: Use controller-runtime patterns for reconcilers. (link)

Comment on lines +70 to +75
exposureClass:
name: ""
backend: externalIPs # externalIPs (default) | metallb | cilium | robotlb
addresses: [] # VIP CIDRs or start-end ranges (metallb, cilium backends)
l2: true # L2 (ARP/NDP) announcement for bare-metal backends
isDefault: true # resolve no-class ServiceExposures to this class

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

low

Add the optional interfaces field to the default values.yaml configuration under exposureClass to document its availability and allow operators to scope L2 announcements to specific host network interfaces.

  exposureClass:
    name: ""
    backend: externalIPs # externalIPs (default) | metallb | cilium | robotlb
    addresses: [] # VIP CIDRs or start-end ranges (metallb, cilium backends)
    interfaces: [] # Optional host network interfaces to scope L2 announcement
    l2: true # L2 (ARP/NDP) announcement for bare-metal backends
    isDefault: true # resolve no-class ServiceExposures to this class

Comment on lines +361 to +366
if err := c.List(ctx, list, client.MatchingLabels{managedByLabel: managedByValue}); err != nil {
if meta.IsNoMatchError(err) || apierrors.IsNotFound(err) {
continue // CRD for this backend not installed; nothing to GC
}
return fmt.Errorf("list %s for GC: %w", gvk.Kind, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

low

Use the helper function backendCRDMissing(err) to check for missing CRDs instead of manually checking meta.IsNoMatchError(err). This ensures consistency across the codebase and correctly handles runtime.IsNotRegisteredError(err) which is crucial for unit tests and certain client setups.

Suggested change
if err := c.List(ctx, list, client.MatchingLabels{managedByLabel: managedByValue}); err != nil {
if meta.IsNoMatchError(err) || apierrors.IsNotFound(err) {
continue // CRD for this backend not installed; nothing to GC
}
return fmt.Errorf("list %s for GC: %w", gvk.Kind, err)
}
if err := c.List(ctx, list, client.MatchingLabels{managedByLabel: managedByValue}); err != nil {
if backendCRDMissing(err) || apierrors.IsNotFound(err) {
continue // CRD for this backend not installed; nothing to GC
}
return fmt.Errorf("list %s for GC: %w", gvk.Kind, err)
}
References
  1. Go Code Standards: Handle errors explicitly. (link)

Comment on lines +47 to +52
func reasonOf(err error) string {
if re, ok := err.(*reconcileError); ok {
return re.reason
}
return "ReconcileError"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

low

Use errors.As to extract the custom reconcileError instead of a direct type assertion. If the error is wrapped (e.g., via fmt.Errorf), the direct type assertion will fail, and it will default to "ReconcileError". Note that you will need to import the standard "errors" package in this file.

Suggested change
func reasonOf(err error) string {
if re, ok := err.(*reconcileError); ok {
return re.reason
}
return "ReconcileError"
}
func reasonOf(err error) string {
var re *reconcileError
if errors.As(err, &re) {
return re.reason
}
return "ReconcileError"
}

Comment on lines +169 to +176
if strings.Contains(a, "-") && !strings.Contains(a, "/") {
parts := strings.SplitN(a, "-", 2)
blocks = append(blocks, map[string]interface{}{
"start": strings.TrimSpace(parts[0]),
"stop": strings.TrimSpace(parts[1]),
})
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

low

Validate that both start and stop are non-empty after trimming. If the input range is malformed (e.g., "192.168.1.1-" or "-192.168.1.10"), one of them will be empty, which would produce an invalid block for Cilium and cause it to reject the pool.

		if strings.Contains(a, "-") && !strings.Contains(a, "/") {
			parts := strings.SplitN(a, "-", 2)
			start := strings.TrimSpace(parts[0])
			stop := strings.TrimSpace(parts[1])
			if start != "" && stop != "" {
				blocks = append(blocks, map[string]interface{}{
					"start": start,
					"stop":  stop,
				})
			}
			continue
		}

IvanHunters
IvanHunters previously approved these changes Jun 26, 2026

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

LGTM with non-blocking notes

The PR is mechanically clean, semantically sound, and behaves identically to today's clusters until an operator opts in by setting publishing.exposureClass.name. CRDs ship via the cozystack-controller chart's definitions/* glob, no migration is required (purely additive), upgrade- and fresh-install paths are both safe. Notes below are MINOR.

Findings

[MINOR] hack/e2e-apps/serviceexposure.bats:21,31 — setup() and teardown() shell functions are silently dead code under cozystack's bats runner.

hack/cozytest.sh:88-89 is explicit: "there are no bats setup/teardown directives here, this runner only knows test + bash." The awk converter (hack/cozytest.sh:118-135) recognises only ^@test "..."$ and emits test_* functions; the setup() and teardown() definitions are sourced into TMP_SH but never called. docs/agents/e2e-testing.md:31-36 reinforces this: do pre-cleanup inline at test start and inline cleanup at test end, no trap-based or hook-based cleanup. Concrete effect here: the second @test reuses cluster state created by the first (CLASS / SVC / EXP all carry over), so it passes against stale state without exercising the deletion path against a fresh resource. Fix by inlining kubectl ... delete --ignore-not-found ... at the top of each @test and at the end (the second @test already inlines the trailing cleanup; the first does not), or define a cozy_cleanup function which the EXIT trap honours.

[MINOR] api/network/v1alpha1/serviceexposure_types.go:34 — ServiceRef accepts an empty name.

corev1.LocalObjectReference emits name with default: "" and the surrounding properties block in the generated CRD has no required: [name]. A ServiceExposure created with spec.serviceRef: {} is admitted; the controller then reports Ready=False/ServiceNotFound against a lookup for "". Adding +kubebuilder:validation:Required plus +kubebuilder:validation:MinLength=1 on a wrapping field (or a custom ServiceReference type) catches the misconfiguration at admission instead of as a reconcile failure.

[MINOR] PR title scope feat(network) is not in the auto-labeler map.

.github/workflows/pr-labeler.yaml maps ingress, gateway, metallb, cilium, kube-ovn, etc. to area/networking, but network is not in the table, so this PR landed with both area/networking (manual) and area/uncategorized (auto-labeler fallback, which AGENTS.md describes as the "needs manual area selection" signal). Either rename the scope to networking (consistent with neighbours), or extend the labeler map with 'network': 'area/networking'.

Operational risks

  • A ServiceExposure whose class resolves to backend metallb/cilium when the corresponding CRD is not installed surfaces as Ready=False/BackendUnavailable. The deletion-time reconcile in reconciler.go:93-108 falls through to a destructive class-wide reclaim only on BackendRenderError / BackendUnsupported — a BackendUnavailable leaves the finalizer pinning the SE until the operator either reinstalls the backend, switches the class to a different backend, or manually clears the finalizer. The conservative behaviour is correct; an operator-facing note (NOTES.txt or a doc snippet linked from the SE CRD description) on the unwedge recipe would save a future pager incident.

Caveats

  • ciliumBlocks (backend/backend.go:161-180) classifies an address as a range iff it contains - and no /, else CIDR. A bare IPv6 like ::1 falls into the CIDR branch and is rendered as cidr: "::1", which CiliumLoadBalancerIPPool rejects (needs an explicit prefix). Edge case, not regression-blocking.
  • Two @test blocks in hack/e2e-apps/serviceexposure.bats exercise only the externalIPs backend; the metallb and cilium rendering paths rely entirely on the Go unit tests. The file's own header comment justifies this (avoids collisions with real LB pools / L2 reachability), and unit coverage in reconciler_test.go / backend_test.go is thorough — flagging as caveat, not finding.
  • publishing.exposureClass.isDefault defaults to true in packages/core/platform/values.yaml. With name: "" the whole block is gated off, but an operator who later sets name without explicitly setting isDefault: false will silently install a cluster-default class — any future ServiceExposure with an empty exposureClassName will resolve to it. Documented in the comment, but worth a NOTES.txt callout for operators copying values.

Recommended follow-ups

  • Define an ApplicationDefinition for ServiceExposure (cozyrds pattern) so tenants surface it in the dashboard catalog — explicitly deferred to a later phase in the PR body, but tracking here so it isn't forgotten when the per-app exposure conversion happens.
  • Consider a kubebuilder:validation:Pattern (or webhook) on ExposureClass.spec.addresses rejecting bare IPv6 entries for the cilium backend so the bug above can't reach the cluster.

Add a deterministic e2e for the externalIPs backend: it creates an
ExposureClass, a Service with pinned externalIPs, and a ServiceExposure,
then asserts the controller resolves the class, renders no MetalLB/Cilium
pool, reports the assigned IPs, and cleans up via its finalizer on delete.

The externalIPs path is chosen so the test creates no backend pool — it
cannot collide with the cluster's existing LoadBalancer address pools or
depend on real L2 reachability. The metallb and cilium rendering paths are
covered by the Go unit tests.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — clean, opt-in foundation for mechanism-agnostic LoadBalancer exposure; the default externalIPs path is verifiably unchanged and the test coverage defines the full contract. The items below are non-blocking polish.

Business context: lays the API + controller foundation to migrate tenant LoadBalancer exposure off the deprecated Service.spec.externalIPs (KEP-5707) and eventually off MetalLB onto Cilium-native announcement — the mechanism-agnostic shape prescribed after the chart-side approach (#2468) was reverted in #2603. Part of #2586; converting per-app exposure, flipping defaults, and removing MetalLB are deferred to later phases.

Verified green: go build + go vet clean; 29/29 Go unit tests pass; re-running controller-gen leaves zero codegen drift (CRDs + deepcopy in sync); platform 75/75 and ingress 18/18 helm-unittests pass; the hardcoded Cilium apiVersions (cilium.io/v2 pool, cilium.io/v2alpha1 L2 policy) match the shipped Cilium v1.19.5; and with publishing.exposureClass.name: "" both new templates render nothing, so the default host-ingress path is byte-for-byte unchanged.

Non-blocking follow-ups

  1. Template isDefault not defaulted in-template like l2 is — exposureclass.yaml gates the default-class annotation on {{- if .isDefault }} while l2 uses dig "l2" true .. Through normal values overrides this is harmless (Helm map-merge keeps the chart's isDefault: true default — verified), so the "default class silently dropped" concern doesn't materialize via values; it only bites a caller that hands the chart a fully-reconstructed map without the key. Mirroring the l2 pattern would make the two optional booleans behave identically and self-document. (inline)

  2. No validation of malformed VIP ranges in ciliumBlocks — an address like "192.168.1.1-" yields {start, stop:""}, which Cilium rejects at apply time, leaving the exposure requeuing on a generic Create error rather than a clear Ready=False reason. Admin-supplied input and it fails loud at the cluster, so not a blocker, but a non-empty start/stop check (or a CRD pattern) returning a BackendRenderError would improve operability. MetalLB passes addresses through verbatim (MetalLB validates its own). (inline)

  3. Finalizer-add doesn't return early before runReconcileSteps (reconciler.go) — functionally safe here (the Update refreshes resourceVersion so the later Status().Update won't conflict, and MaxConcurrentReconciles defaults to 1), so this is optional best-practice polish, not a fix.

  4. Docs — no user-facing docs ship with this phase, which is fine for an opt-in foundation, but publishing.exposureClass.* is a new operator-facing knob; the migration story is worth landing against #2586 when defaults flip.

Strengths worth preserving: the class-level one-pool-per-ExposureClass model sidesteps the overlapping-pool problem (covered by TestReconcile_SecondExposureSharesOnePool); the subset-merge in applyObject (reconcile only ManagedSpecKeys, leave apiserver-defaulted keys intact) is proven idempotent and non-destructive; the finalizer/cross-scope cleanup and take-over guard are unusually thorough; and the fail guard turns the pool-backend + publishing.externalIPs dead-end into a loud error.

Note: dual-model leg was Claude-only (Codex unavailable in env); bot findings independently re-verified, build/test/codegen checks run directly.

kind: ExposureClass
metadata:
name: {{ .name | quote }}
{{- if .isDefault }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor consistency nit: l2 below is defaulted in-template via dig "l2" true ., but isDefault here relies solely on the chart's values.yaml default. Through normal values overrides this is fine (Helm map-merge preserves isDefault: true, verified with a throwaway unittest), but mirroring the pattern — {{- if dig "isDefault" true . }} — would make the two optional booleans behave identically and not depend on the values.yaml default being present. Non-blocking.

}
// A range "start-stop" has a dash and no slash; a CIDR has a slash.
if strings.Contains(a, "-") && !strings.Contains(a, "/") {
parts := strings.SplitN(a, "-", 2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A malformed range like "192.168.1.1-" yields {start, stop:""}, which Cilium rejects at apply time — the exposure then requeues on a generic Create error instead of a clear Ready=False reason. Consider validating both start and stop are non-empty (and returning a BackendRenderError so the status reason is legible). Admin-supplied input so not a blocker, and MetalLB validates its own addresses, but it would improve operability. No test currently covers the malformed-range path (backend_test.go only exercises well-formed CIDR/range).

myasnikovdaniil added a commit that referenced this pull request Jul 8, 2026
Main added securitygroup.bats (#2922 membership model) and
serviceexposure.bats (#3081 ExposureClass/ServiceExposure) after the
Chainsaw migration branch was cut.

- securitygroup: two Tests — the aggregated-API projection round-trip
  (backing CNP marker label, empty-value membership endpointSelector,
  lineage fromApp + membership fromSG peers, 1:1 rule translation, the
  view hiding storage internals, unmarked-CNP invisibility via an error
  op, delete propagation) and the controller membership-label
  stamp/clear lifecycle. Only the attachments annotation stays a jq
  script (it is a JSON string). Chainsaw auto-cleanup covers every
  fixture even on mid-test failure — the bats had no teardown hook and
  leaked its fixtures.
- serviceexposure: the externalIPs backend resolve/Ready/assignedIPs
  path with the no-MetalLB-pool negative as an error op (ordered after
  resolvedBackend so it cannot false-pass), and the finalizer-removal
  test collapsed onto the delete op itself, which waits for full
  disappearance.
- selection: kubernetes-application now also maps to the two OIDC
  render-side suites (they exercise the same app chart — a gap main's
  bats selection had too); the new dirs enter all_apps discovery
  automatically. Selector unit tests extended and green (13/13 under
  cozytest).
- README: suites list updated.

Assisted-By: Claude <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Andrei Kvapil (kvaps) added a commit that referenced this pull request Jul 16, 2026
…BalancerClass (#3218)

## What

Removes the `network.cozystack.io` API group — `ExposureClass` +
`ServiceExposure` (#3081) — and its reconciler, in favor of native
`Service` `type: LoadBalancer` + `loadBalancerClass`.

## Why

Full rationale and discussion in #3164. For **managed applications** the
chart already owns the Service, so:

- `type: LoadBalancer` + `loadBalancerClass` + an admin-provisioned pool
covers vendor-neutral external exposure without a dedicated API group
and controller;
- the `externalIPs` (node-IP) default never went through
`ServiceExposure` (its backend is a no-op) and is left unchanged;
- the only current consumer, the host ingress, keeps rendering `type:
LoadBalancer` and just drops the `ServiceExposure`; the admin provisions
the address pool / announcer out of band, as the install docs already
assume.

`ServiceExposure`'s one unique feature — dynamic per-namespace scoping
of a shared VIP pool — is a convenience the managed-app path does not
need, and it does not justify a second tenant-networking API group next
to `sdn.cozystack.io`.

## Changes

- Delete `api/network/v1alpha1` and
`internal/controller/serviceexposure` (+ CRD definitions, e2e bats,
controller registration, RBAC).
- Host ingress: drop the `ServiceExposure` template; add an optional
`publishing.loadBalancerClass` that sets `spec.loadBalancerClass` on the
host-ingress LoadBalancer Service.
- The `externalIPs` path and default behavior are unchanged.

## Verification

- `go build ./...` — green.
- `helm unittest` — `core/platform` 72/72, `extra/ingress` 25/25 (incl.
host-vs-tenant `loadBalancerClass` scoping and the immutable-field
migration guard).

## Migration

None required. `network.cozystack.io` (#3081) has never shipped in a
release — it is on `main` only (not backported to `release-1.5`; absent
from v1.4.x / v1.5.x), so no upgrading cluster has these CRDs and this
removes it before it is ever released.

Clusters that track `main` and explicitly set `publishing.exposureClass`
(opt-in, only since 2026-06-30) should provision their MetalLB / Cilium
address pool themselves and set `publishing.loadBalancerClass`; any
orphaned `cozystack-<class>` pool can be deleted by hand. Default
clusters (externalIPs path) are unaffected.

## Status

**Draft / RFC for the #3164 discussion** — this removes an API group
that landed only days ago, so nothing here should merge before the
design call. cc @lexfrei (author of #3081) and @lllamnyp (#3164).

Deferred: when Kubernetes actually removes `Service.spec.externalIPs`,
the node-IP default migrates via a MetalLB `IPAddressPool` with no
`L2Advertisement` (allocate-but-don't-announce) — a separate change.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for configuring the ingress host Service with a native
`loadBalancerClass` value.
* Expanded chart tests to verify class wiring and immutability behavior
across install and upgrade scenarios.

* **Bug Fixes**
* Removed the legacy exposure-class/service-exposure flow and related
controller behavior.
* Updated templates and CRD packaging so the platform no longer renders
or depends on those deprecated resources.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/networking Issues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/api-change Categorizes issue or PR as related to adding, removing, or otherwise changing an API kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants