Repository navigation
feat(network): mechanism-agnostic LoadBalancer exposure (ExposureClass + ServiceExposure) - #3081
Conversation
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]>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds 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. ChangesService exposure control flow
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
hack/e2e-apps/serviceexposure.bats (1)
129-131: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDrop the redundant inline fixture cleanup.
teardown()(Lines 32-34) already deletes theserviceandexposureclassafter every test, so these inline deletes are redundant. The deletion test should assert theServiceExposureremoval (Lines 126-127) and leave fixture teardown to the framework.Based on learnings: replace inline cleanup in E2E bats tests with framework-level teardown rather than per-test cleanup.♻️ 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 }🤖 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 | 🔵 TrivialReplace direct type assertion with
errors.AsThe type assertion
err.(*reconcileError)is fragile. Althoughfailfcurrently returns errors directly, any future wrapping viafmt.Errorf("...: %w")would causereasonOfto lose the specific reason and fallback to"ReconcileError". Useerrors.Asto 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
📒 Files selected for processing (31)
api/network/v1alpha1/exposureclass_types.goapi/network/v1alpha1/groupversion_info.goapi/network/v1alpha1/serviceexposure_types.goapi/network/v1alpha1/zz_generated.deepcopy.gocmd/cozystack-controller/main.gohack/e2e-apps/serviceexposure.batshack/update-codegen.shinternal/controller/serviceexposure/backend/backend.gointernal/controller/serviceexposure/backend/backend_test.gointernal/controller/serviceexposure/backend/cilium.gointernal/controller/serviceexposure/backend/externalips.gointernal/controller/serviceexposure/backend/metallb.gointernal/controller/serviceexposure/backend/registry.gointernal/controller/serviceexposure/backend/robotlb.gointernal/controller/serviceexposure/classreconciler.gointernal/controller/serviceexposure/conflict.gointernal/controller/serviceexposure/mapper.gointernal/controller/serviceexposure/mapper_test.gointernal/controller/serviceexposure/reconciler.gointernal/controller/serviceexposure/reconciler_test.gointernal/controller/serviceexposure/status.gopackages/core/platform/templates/apps.yamlpackages/core/platform/templates/exposureclass.yamlpackages/core/platform/tests/apps_exposure_class_test.yamlpackages/core/platform/tests/exposureclass_wiring_test.yamlpackages/core/platform/values.yamlpackages/extra/ingress/templates/serviceexposure.yamlpackages/extra/ingress/tests/serviceexposure_test.yamlpackages/system/cozystack-controller/definitions/network.cozystack.io_exposureclasses.yamlpackages/system/cozystack-controller/definitions/network.cozystack.io_serviceexposures.yamlpackages/system/cozystack-controller/templates/rbac.yaml
| 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" |
There was a problem hiding this comment.
🩺 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:
- 1: https://docs.cilium.io/en/v1.16/network/lb-ipam/
- 2: https://docs.cilium.io/en/v1.15/network/lb-ipam/
- 3: LB-IPAM: Change default CiliumLoadBalancerIPPool behavior and remove deprecated cidrs field from CiliumLoadBalancerIPPool cilium/cilium#33151
- 4: https://github.com/cilium/cilium/blob/v1.16.0/CHANGELOG.md
- 5: https://docs.cilium.io/en/v1.16/network/l2-announcements/
- 6: https://doc.crds.dev/github.com/cilium/cilium/cilium.io/CiliumL2AnnouncementPolicy/[email protected]
- 7: https://docs.cilium.io/en/latest/network/l2-announcements/
- 8: https://docs.cilium.io/en/stable/network/l2-announcements/
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.
| {{- if .isDefault }} | ||
| annotations: | ||
| exposureclass.network.cozystack.io/is-default-class: "true" | ||
| {{- end }} |
There was a problem hiding this comment.
🎯 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.
| {{- 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.
Summary of ChangesHello, 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 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
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| {{- with .addresses }} | ||
| addresses: | ||
| {{- toYaml . | nindent 4 }} | ||
| {{- end }} |
There was a problem hiding this comment.
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 }}| if controllerutil.AddFinalizer(exp, exposureFinalizer) { | ||
| if err := r.Update(ctx, exp); err != nil { | ||
| return ctrl.Result{}, client.IgnoreNotFound(err) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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
- Go Code Standards: Use controller-runtime patterns for reconcilers. (link)
| 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 |
There was a problem hiding this comment.
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| 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) | ||
| } |
There was a problem hiding this comment.
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.
| 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
- Go Code Standards: Handle errors explicitly. (link)
| func reasonOf(err error) string { | ||
| if re, ok := err.(*reconcileError); ok { | ||
| return re.reason | ||
| } | ||
| return "ReconcileError" | ||
| } |
There was a problem hiding this comment.
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.
| 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" | |
| } |
| 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 | ||
| } |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
ServiceExposurewhose class resolves to backendmetallb/ciliumwhen the corresponding CRD is not installed surfaces asReady=False/BackendUnavailable. The deletion-time reconcile inreconciler.go:93-108falls through to a destructive class-wide reclaim only onBackendRenderError/BackendUnsupported— aBackendUnavailableleaves 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 CRDdescription) 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::1falls into the CIDR branch and is rendered ascidr: "::1", whichCiliumLoadBalancerIPPoolrejects (needs an explicit prefix). Edge case, not regression-blocking.- Two
@testblocks inhack/e2e-apps/serviceexposure.batsexercise only theexternalIPsbackend; themetallbandciliumrendering 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 inreconciler_test.go/backend_test.gois thorough — flagging as caveat, not finding. publishing.exposureClass.isDefaultdefaults totrueinpackages/core/platform/values.yaml. Withname: ""the whole block is gated off, but an operator who later setsnamewithout explicitly settingisDefault: falsewill silently install a cluster-default class — any futureServiceExposurewith an emptyexposureClassNamewill resolve to it. Documented in the comment, but worth a NOTES.txt callout for operators copying values.
Recommended follow-ups
- Define an
ApplicationDefinitionforServiceExposure(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) onExposureClass.spec.addressesrejecting bare IPv6 entries for theciliumbackend 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]>
9d2d687 to
511f2a3
Compare
myasnikovdaniil
left a comment
There was a problem hiding this comment.
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
-
Template
isDefaultnot defaulted in-template likel2is —exposureclass.yamlgates the default-class annotation on{{- if .isDefault }}whilel2usesdig "l2" true .. Through normal values overrides this is harmless (Helm map-merge keeps the chart'sisDefault: truedefault — 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 thel2pattern would make the two optional booleans behave identically and self-document. (inline) -
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 clearReady=Falsereason. Admin-supplied input and it fails loud at the cluster, so not a blocker, but a non-empty start/stop check (or a CRDpattern) returning aBackendRenderErrorwould improve operability. MetalLB passes addresses through verbatim (MetalLB validates its own). (inline) -
Finalizer-add doesn't return early before
runReconcileSteps(reconciler.go) — functionally safe here (theUpdaterefreshes resourceVersion so the laterStatus().Updatewon't conflict, andMaxConcurrentReconcilesdefaults to 1), so this is optional best-practice polish, not a fix. -
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 }} |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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).
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]>
…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 -->
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/v1alpha1API 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.serviceexposurecontroller renders the backend's pool + announcer once per class (MetalLBIPAddressPool+L2Advertisement; CiliumCiliumLoadBalancerIPPool+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 adminExposureClassand 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 singleExposureClass.spec.backendchange.This is the foundation only — Part of #2586. Converting per-app exposure, flipping Cilium
l2announcementson, changing the default, and removing MetalLB are deferred to later phases.Release note
Summary by CodeRabbit
ExposureClassandServiceExposurecustom resources with support for multiple backends (externalIPs, MetalLB, Cilium, RobotLB), including optional L2 and address configuration.ServiceExposure, selecting a configured (or default)ExposureClass.