refactor(network): drop ExposureClass/ServiceExposure for native loadBalancerClass

Remove the network.cozystack.io API group (ExposureClass + ServiceExposure)
and its reconciler. Managed-application charts own their Service, so native
type: LoadBalancer + loadBalancerClass + an admin-provisioned pool covers
external exposure without a dedicated API group and controller. The host
ingress -- the only consumer -- keeps rendering type: LoadBalancer and gains
an optional publishing.loadBalancerClass to pick the LB controller; the
externalIPs (node-IP) default path is unchanged.

Rationale in cozystack#3164.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
This commit is contained in:
Andrei Kvapil authored and Aleksei Sviridkin committed 2026-07-07 12:39:30 +03:00
1 parent 63fcebd804
commit 3dbc7d3662
32 files changed
+16 -3795

No files matched your search

-135
View File
@@ -1,135 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// IsDefaultExposureClassAnnotation marks an ExposureClass as the
// cluster default, mirroring the storageclass.kubernetes.io/is-default-class
// convention. A ServiceExposure with an empty exposureClassName resolves
// to the ExposureClass carrying this annotation set to "true".
const IsDefaultExposureClassAnnotation = "exposureclass.network.cozystack.io/is-default-class"
// ExposureBackend names the cluster-side mechanism that allocates a
// LoadBalancer IP and announces it on the network. A backend covers BOTH
// allocation and announcement: for bare-metal the VIP pool and the L2
// announcer are inseparable.
// +kubebuilder:validation:Enum=externalIPs;metallb;cilium;robotlb
type ExposureBackend string
const (
// BackendExternalIPs pins the Service to operator-supplied addresses
// via Service.spec.externalIPs (typically node public IPs). No pool
// and no announcer are rendered — the node already owns the address.
// This is the default and reproduces the historical behaviour.
BackendExternalIPs ExposureBackend = "externalIPs"
// BackendMetalLB allocates from a metallb.io IPAddressPool and
// announces via an L2Advertisement, both rendered by the controller
// in the cozy-metallb namespace.
BackendMetalLB ExposureBackend = "metallb"
// BackendCilium allocates from a CiliumLoadBalancerIPPool and
// announces via a CiliumL2AnnouncementPolicy, both rendered by the
// controller (cluster-scoped).
BackendCilium ExposureBackend = "cilium"
// BackendRobotLB delegates allocation and announcement to a cloud
// load balancer (e.g. Hetzner robotlb). The controller renders no
// in-cluster pool or announcer; it only reports the assigned IP.
BackendRobotLB ExposureBackend = "robotlb"
)
// ExposureClassSpec is the cluster-admin backend configuration. It maps a
// logical exposure class to a concrete backend and its address scope,
// following the StorageClass / IngressClass / GatewayClass pattern: the
// admin owns the backend choice, tenants reference the class by name.
type ExposureClassSpec struct {
// Backend selects the concrete allocation+announcement mechanism.
// +required
Backend ExposureBackend `json:"backend"`
// Addresses is the VIP address scope for pool-based backends (metallb,
// cilium), as CIDRs or start-end ranges. These are virtual VIPs, NOT
// node public IPs. Ignored by the externalIPs and robotlb backends.
// +optional
Addresses []string `json:"addresses,omitempty"`
// L2 enables L2 (ARP/NDP) announcement for bare-metal backends
// (metallb, cilium). Defaults to true. Set false when announcement is
// handled out of band (e.g. BGP added later, or a routed network).
// +kubebuilder:default=true
// +optional
L2 *bool `json:"l2,omitempty"`
// Interfaces optionally scopes L2 announcement to the named host
// network interfaces (cilium CiliumL2AnnouncementPolicy.spec.interfaces;
// metallb L2Advertisement.spec.interfaces). Empty announces on all.
// +optional
Interfaces []string `json:"interfaces,omitempty"`
}
// ExposureClassStatus reports the observed state of the ExposureClass.
type ExposureClassStatus struct {
// ObservedGeneration mirrors the .metadata.generation reflected in the
// latest reconciled state.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// Conditions describes the current state of the ExposureClass.
// Standard condition type: Ready.
// +optional
// +patchMergeKey=type
// +patchStrategy=merge
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster,shortName=expcls
// +kubebuilder:printcolumn:name="Backend",type="string",JSONPath=".spec.backend"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// ExposureClass is the Schema for the exposureclasses API. It is the
// cluster-admin-facing configuration that binds a logical class name to a
// LoadBalancer backend and its address scope. Tenants reference it by name
// from a ServiceExposure; the cozystack-controller renders the backend's
// pool and announcer resources from this CR.
type ExposureClass struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ExposureClassSpec `json:"spec,omitempty"`
Status ExposureClassStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// ExposureClassList contains a list of ExposureClass.
type ExposureClassList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ExposureClass `json:"items"`
}
func init() {
SchemeBuilder.Register(&ExposureClass{}, &ExposureClassList{})
}
-37
View File
@@ -1,37 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package v1alpha1 contains API Schema definitions for the
// network.cozystack.io v1alpha1 API group.
// +kubebuilder:object:generate=true
// +groupName=network.cozystack.io
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// GroupVersion is group version used to register these objects.
GroupVersion = schema.GroupVersion{Group: "network.cozystack.io", Version: "v1alpha1"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -1,104 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ServiceExposureSpec is the mechanism-agnostic tenant intent: "expose
// this Service externally via the named ExposureClass". It never names a
// backend; the controller translates it into whichever backend the cluster
// admin wired behind the referenced class.
type ServiceExposureSpec struct {
// ServiceRef names the Service in this namespace to expose. The
// controller scopes the backend pool/announcer to that Service and
// reads its assigned IP back into status. The Service's own type is
// owned by the chart that renders it, not by this controller.
// +required
ServiceRef corev1.LocalObjectReference `json:"serviceRef"`
// ExposureClassName references a cluster-scoped ExposureClass the
// admin configured. Empty selects the cluster default ExposureClass
// (the one annotated exposureclass.network.cozystack.io/is-default-class=true).
// +optional
ExposureClassName string `json:"exposureClassName,omitempty"`
}
// ServiceExposureStatus reports the observed state of the exposure.
type ServiceExposureStatus struct {
// ObservedGeneration mirrors the .metadata.generation reflected in the
// latest reconciled state.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// ResolvedBackend records which backend the referenced class resolved
// to, so operators can see where an exposure was routed.
// +optional
ResolvedBackend string `json:"resolvedBackend,omitempty"`
// AssignedIPs reports the external addresses the backend assigned,
// read back from the Service (status.loadBalancer.ingress or the
// externalIPs pin).
// +optional
AssignedIPs []string `json:"assignedIPs,omitempty"`
// Conditions describes the current state of the ServiceExposure.
// Standard condition type: Ready.
// +optional
// +patchMergeKey=type
// +patchStrategy=merge
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced,shortName=svcexp
// +kubebuilder:printcolumn:name="Service",type="string",JSONPath=".spec.serviceRef.name"
// +kubebuilder:printcolumn:name="Class",type="string",JSONPath=".spec.exposureClassName"
// +kubebuilder:printcolumn:name="Backend",type="string",JSONPath=".status.resolvedBackend"
// +kubebuilder:printcolumn:name="IPs",type="string",JSONPath=".status.assignedIPs"
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// ServiceExposure is the Schema for the serviceexposures API. It expresses
// the mechanism-agnostic tenant intent to expose a Service externally; the
// cozystack-controller reconciles the backend-specific pool and announcer
// resources from this CR and reports the assigned IPs in status.
type ServiceExposure struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ServiceExposureSpec `json:"spec,omitempty"`
Status ServiceExposureStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// ServiceExposureList contains a list of ServiceExposure.
type ServiceExposureList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ServiceExposure `json:"items"`
}
func init() {
SchemeBuilder.Register(&ServiceExposure{}, &ServiceExposureList{})
}
@@ -1,239 +0,0 @@
//go:build !ignore_autogenerated
/*
Copyright 2025 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExposureClass) DeepCopyInto(out *ExposureClass) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExposureClass.
func (in *ExposureClass) DeepCopy() *ExposureClass {
if in == nil {
return nil
}
out := new(ExposureClass)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ExposureClass) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExposureClassList) DeepCopyInto(out *ExposureClassList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ExposureClass, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExposureClassList.
func (in *ExposureClassList) DeepCopy() *ExposureClassList {
if in == nil {
return nil
}
out := new(ExposureClassList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ExposureClassList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExposureClassSpec) DeepCopyInto(out *ExposureClassSpec) {
*out = *in
if in.Addresses != nil {
in, out := &in.Addresses, &out.Addresses
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.L2 != nil {
in, out := &in.L2, &out.L2
*out = new(bool)
**out = **in
}
if in.Interfaces != nil {
in, out := &in.Interfaces, &out.Interfaces
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExposureClassSpec.
func (in *ExposureClassSpec) DeepCopy() *ExposureClassSpec {
if in == nil {
return nil
}
out := new(ExposureClassSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExposureClassStatus) DeepCopyInto(out *ExposureClassStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExposureClassStatus.
func (in *ExposureClassStatus) DeepCopy() *ExposureClassStatus {
if in == nil {
return nil
}
out := new(ExposureClassStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceExposure) DeepCopyInto(out *ServiceExposure) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExposure.
func (in *ServiceExposure) DeepCopy() *ServiceExposure {
if in == nil {
return nil
}
out := new(ServiceExposure)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ServiceExposure) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceExposureList) DeepCopyInto(out *ServiceExposureList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ServiceExposure, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExposureList.
func (in *ServiceExposureList) DeepCopy() *ServiceExposureList {
if in == nil {
return nil
}
out := new(ServiceExposureList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ServiceExposureList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceExposureSpec) DeepCopyInto(out *ServiceExposureSpec) {
*out = *in
out.ServiceRef = in.ServiceRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExposureSpec.
func (in *ServiceExposureSpec) DeepCopy() *ServiceExposureSpec {
if in == nil {
return nil
}
out := new(ServiceExposureSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceExposureStatus) DeepCopyInto(out *ServiceExposureStatus) {
*out = *in
if in.AssignedIPs != nil {
in, out := &in.AssignedIPs, &out.AssignedIPs
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExposureStatus.
func (in *ServiceExposureStatus) DeepCopy() *ServiceExposureStatus {
if in == nil {
return nil
}
out := new(ServiceExposureStatus)
in.DeepCopyInto(out)
return out
}
-19
View File
@@ -37,10 +37,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/webhook"
gatewayv1alpha1 "github.com/cozystack/cozystack/api/gateway/v1alpha1"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
cozystackiov1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
"github.com/cozystack/cozystack/internal/controller"
"github.com/cozystack/cozystack/internal/controller/serviceexposure"
"github.com/cozystack/cozystack/internal/controller/tenantgateway"
"github.com/cozystack/cozystack/internal/controller/tenantquota"
"github.com/cozystack/cozystack/internal/telemetry"
@@ -63,7 +61,6 @@ func init() {
utilruntime.Must(cozystackiov1alpha1.AddToScheme(scheme))
utilruntime.Must(gatewayv1alpha1.AddToScheme(scheme))
utilruntime.Must(networkv1alpha1.AddToScheme(scheme))
utilruntime.Must(gatewayv1.Install(scheme))
utilruntime.Must(gatewayv1alpha2.Install(scheme))
utilruntime.Must(cmv1.AddToScheme(scheme))
@@ -233,22 +230,6 @@ func main() {
os.Exit(1)
}
if err = (&serviceexposure.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ServiceExposure")
os.Exit(1)
}
if err = (&serviceexposure.ClassReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ExposureClass")
os.Exit(1)
}
if err = (&tenantquota.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
-132
View File
@@ -1,132 +0,0 @@
#!/usr/bin/env bats
# Smoke test for the ServiceExposure controller (network.cozystack.io).
#
# This exercises the externalIPs backend end-to-end on a live cluster: the
# CRDs are installed, the controller resolves a ServiceExposure to its
# ExposureClass, renders NO foreign backend CRs for the externalIPs
# backend, and reports status. The externalIPs path is chosen deliberately
# so the test creates no MetalLB/Cilium pool — it cannot collide with the
# cluster's existing LoadBalancer address pools or depend on real L2
# reachability, keeping it deterministic. The metallb and cilium rendering
# paths (pool + announcer CRs, garbage collection, take-over guard) are
# covered exhaustively by the Go unit tests in
# internal/controller/serviceexposure.
CLASS=se-e2e-externalips
SVC=se-e2e-svc
EXP=se-e2e
POOL=cozystack-${CLASS} # backend.PoolName = cozystack-<class> (class-level)
setup() {
kubectl -n tenant-test delete serviceexposure "$EXP" --ignore-not-found --timeout=2m
kubectl -n tenant-test wait serviceexposure/"$EXP" --for=delete --timeout=2m
kubectl -n tenant-test delete service "$SVC" --ignore-not-found --timeout=1m
kubectl delete exposureclass "$CLASS" --ignore-not-found --timeout=1m
}
# teardown runs after every test, so a test that fails mid-way does not
# leave the cluster-scoped ExposureClass (or the namespaced fixtures)
# behind for the next test or the next CI run.
teardown() {
kubectl -n tenant-test delete serviceexposure "$EXP" --ignore-not-found --timeout=2m
kubectl -n tenant-test delete service "$SVC" --ignore-not-found --timeout=1m
kubectl delete exposureclass "$CLASS" --ignore-not-found --timeout=1m
}
dump_diagnostics() {
echo "# --- diagnostics ---" >&3
kubectl -n tenant-test get serviceexposure,service -o wide >&3 2>&1 || true
kubectl -n tenant-test describe serviceexposure "$EXP" >&3 2>&1 || true
kubectl get exposureclass "$CLASS" -o yaml >&3 2>&1 || true
kubectl -n cozy-system logs -l app=cozystack-controller --tail=100 >&3 2>&1 || true
}
@test "externalIPs ExposureClass resolves and renders no backend pool" {
kubectl apply -f- <<EOF
apiVersion: network.cozystack.io/v1alpha1
kind: ExposureClass
metadata:
name: ${CLASS}
spec:
backend: externalIPs
---
apiVersion: v1
kind: Service
metadata:
name: ${SVC}
namespace: tenant-test
spec:
type: ClusterIP
externalIPs:
- 203.0.113.10
ports:
- port: 80
targetPort: 80
---
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
metadata:
name: ${EXP}
namespace: tenant-test
spec:
serviceRef:
name: ${SVC}
exposureClassName: ${CLASS}
EOF
# The controller stamps status.resolvedBackend once it has reconciled.
timeout 120 sh -ec "until [ \"\$(kubectl -n tenant-test get serviceexposure ${EXP} -o jsonpath='{.status.resolvedBackend}' 2>/dev/null)\" = 'externalIPs' ]; do sleep 2; done" || { dump_diagnostics; false; }
# externalIPs backend must not create a MetalLB pool.
! kubectl -n cozy-metallb get ipaddresspool "$POOL" 2>/dev/null || { dump_diagnostics; false; }
# The pinned externalIPs make the exposure Ready.
kubectl -n tenant-test wait serviceexposure/"$EXP" --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True --timeout=60s || { dump_diagnostics; false; }
kubectl -n tenant-test get serviceexposure "$EXP" -o jsonpath='{.status.assignedIPs[0]}' | grep -q '^203.0.113.10$' || { dump_diagnostics; false; }
}
@test "deleting the ServiceExposure removes its finalizer and the object" {
# Recreate (setup cleared the previous test's objects).
kubectl apply -f- <<EOF
apiVersion: network.cozystack.io/v1alpha1
kind: ExposureClass
metadata:
name: ${CLASS}
spec:
backend: externalIPs
---
apiVersion: v1
kind: Service
metadata:
name: ${SVC}
namespace: tenant-test
spec:
type: ClusterIP
externalIPs:
- 203.0.113.10
ports:
- port: 80
targetPort: 80
---
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
metadata:
name: ${EXP}
namespace: tenant-test
spec:
serviceRef:
name: ${SVC}
exposureClassName: ${CLASS}
EOF
timeout 120 sh -ec "until kubectl -n tenant-test get serviceexposure ${EXP} -o jsonpath='{.status.resolvedBackend}' 2>/dev/null | grep -q externalIPs; do sleep 2; done" || { dump_diagnostics; false; }
# Deletion must complete despite the cleanup finalizer (nothing to GC for
# the externalIPs backend, so the finalizer is removed promptly).
kubectl -n tenant-test delete serviceexposure "$EXP" --timeout=2m
kubectl -n tenant-test wait serviceexposure/"$EXP" --for=delete --timeout=1m
# 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
}
+1 -1
View File
@@ -91,7 +91,7 @@ kube::codegen::gen_client \
"${SCRIPT_ROOT}/pkg/apis"
$CONTROLLER_GEN object:headerFile="hack/boilerplate.go.txt" paths="./api/..."
$CONTROLLER_GEN rbac:roleName=manager-role crd paths="./api/v1alpha1/..." paths="./api/backups/..." paths="./api/gateway/..." paths="./api/network/..." output:crd:artifacts:config=${TMPDIR}
$CONTROLLER_GEN rbac:roleName=manager-role crd paths="./api/v1alpha1/..." paths="./api/backups/..." paths="./api/gateway/..." output:crd:artifacts:config=${TMPDIR}
mv ${TMPDIR}/cozystack.io_packages.yaml ${OPERATOR_CRDDIR}/cozystack.io_packages.yaml
mv ${TMPDIR}/cozystack.io_packagesources.yaml ${OPERATOR_CRDDIR}/cozystack.io_packagesources.yaml
@@ -1,210 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package backend implements the pluggable LoadBalancer backends the
// serviceexposure controller dispatches to. Each backend translates an
// ExposureClass (the admin's backend + VIP address scope) into the
// concrete cluster resources that allocate a VIP and announce it.
//
// The pool and announcer are CLASS-level, not per-Service: a VIP address
// range is a property of the ExposureClass, and overlapping pools are
// rejected by Cilium LB-IPAM and double-count IPs in MetalLB. So a backend
// renders exactly ONE pool and ONE announcer per class, scoped via the
// pool/policy's own selector to the set of namespaces that have at least
// one ServiceExposure resolving to the class — never per Service, and
// never by mutating the Service.
//
// The pool/announcer CRs belong to foreign API groups (metallb.io,
// cilium.io) not in the controller's Go scheme, so backends emit them as
// *unstructured.Unstructured. Desired is a PURE function of its inputs (no
// client calls) so it is exhaustively unit-testable.
package backend
import (
"strings"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
const (
// MetalLBNamespace is where the cozystack metallb chart installs, and
// therefore where its IPAddressPool / L2Advertisement CRs must live.
MetalLBNamespace = "cozy-metallb"
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"
// serviceNamespaceSelectorKey is the synthetic label key Cilium LB-IPAM
// and L2 announcement understand in a serviceSelector to scope a
// pool/policy to specific namespaces, even though it is not a real
// label on the Service object.
serviceNamespaceSelectorKey = "io.kubernetes.service.namespace"
)
// Backend is one LoadBalancer allocation+announcement mechanism. The
// reconciler resolves an ExposureClass to a Backend and delegates.
// Adding a new backend (BGP, another cloud LB) means implementing this
// interface and registering it — no reconciler or API change.
type Backend interface {
// Name returns the ExposureBackend enum value this backend implements.
Name() networkv1alpha1.ExposureBackend
// Desired returns the CLASS-level cluster CRs (pool + announcer) for
// the class, scoped to the given namespaces — the sorted, unique set
// of namespaces that have at least one ServiceExposure resolving to
// the class. An empty namespace set (no live exposures) returns no
// objects, so the controller garbage-collects the class's CRs. Backends
// without in-cluster resources (externalIPs, robotlb) always return
// nil. Returned objects carry GVK, name and spec but NOT management
// labels/ownership — the reconciler stamps those.
Desired(class *networkv1alpha1.ExposureClass, namespaces []string) ([]*unstructured.Unstructured, error)
// Observe reads the externally-assigned addresses and readiness back
// from a target Service for status reporting.
Observe(svc *corev1.Service) (assignedIPs []string, ready bool, reason string)
}
// ManagedGVKs are every GroupVersionKind any backend may render. The
// reconciler scans these during garbage collection to reclaim CRs whose
// owning ExposureClass changed backend or lost all its exposures.
func ManagedGVKs() []schema.GroupVersionKind {
return []schema.GroupVersionKind{
{Group: "metallb.io", Version: "v1beta1", Kind: "IPAddressPool"},
{Group: "metallb.io", Version: "v1beta1", Kind: "L2Advertisement"},
{Group: "cilium.io", Version: "v2", Kind: "CiliumLoadBalancerIPPool"},
{Group: "cilium.io", Version: "v2alpha1", Kind: "CiliumL2AnnouncementPolicy"},
}
}
// ManagedSpecKeys returns the top-level spec keys this controller owns for
// a given kind. The reconciler add/updates/deletes ONLY these keys when
// converging an existing object, leaving everything else (notably the
// apiserver's CRD-defaulted keys: metallb autoAssign/avoidBuggyIPs, cilium
// disabled/allowFirstLastIPs) untouched. A managed key absent from a fresh
// render is removed, so clearing e.g. class.spec.interfaces propagates.
func ManagedSpecKeys(kind string) []string {
switch kind {
case "IPAddressPool":
return []string{"addresses", "serviceAllocation"}
case "L2Advertisement":
return []string{"ipAddressPools", "interfaces"}
case "CiliumLoadBalancerIPPool":
return []string{"blocks", "serviceSelector"}
case "CiliumL2AnnouncementPolicy":
return []string{"loadBalancerIPs", "serviceSelector", "interfaces"}
}
return nil
}
// PoolName is the deterministic name for the class-level pool/announcer
// CRs. ExposureClass names are cluster-unique DNS-1123 labels, so this is
// globally unique for both the cluster-scoped Cilium CRs and the
// cozy-metallb CRs — no cross-namespace collision is possible.
func PoolName(className string) string {
return "cozystack-" + className
}
// l2Enabled reports whether L2 announcement should be rendered. The API
// default is true; a nil pointer (e.g. an object built in tests without
// admission defaulting) is treated as true.
func l2Enabled(class *networkv1alpha1.ExposureClass) bool {
return class.Spec.L2 == nil || *class.Spec.L2
}
// namespaceServiceSelector builds a label selector that scopes a Cilium
// pool/policy to LoadBalancer Services in the given namespaces, via the
// synthetic io.kubernetes.service.namespace key Cilium understands.
func namespaceServiceSelector(namespaces []string) map[string]interface{} {
return map[string]interface{}{
"matchExpressions": []interface{}{
map[string]interface{}{
"key": serviceNamespaceSelectorKey,
"operator": "In",
"values": toIfaceSlice(namespaces),
},
},
}
}
// toIfaceSlice converts a string slice to []interface{} for unstructured.
func toIfaceSlice(in []string) []interface{} {
out := make([]interface{}, 0, len(in))
for _, s := range in {
out = append(out, s)
}
return out
}
// ciliumBlocks converts class addresses (CIDR or "start-stop" range form)
// into CiliumLoadBalancerIPPool.spec.blocks entries.
func ciliumBlocks(addresses []string) []interface{} {
blocks := make([]interface{}, 0, len(addresses))
for _, a := range addresses {
a = strings.TrimSpace(a)
if a == "" {
continue
}
// 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)
blocks = append(blocks, map[string]interface{}{
"start": strings.TrimSpace(parts[0]),
"stop": strings.TrimSpace(parts[1]),
})
continue
}
blocks = append(blocks, map[string]interface{}{"cidr": a})
}
return blocks
}
// newUnstructured constructs a typed-but-unstructured object with its GVK
// and metadata set. Namespace is left empty for cluster-scoped kinds.
func newUnstructured(apiVersion, kind, namespace, name string, spec map[string]interface{}) *unstructured.Unstructured {
obj := &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": apiVersion,
"kind": kind,
"metadata": map[string]interface{}{"name": name},
"spec": spec,
}}
if namespace != "" {
obj.SetNamespace(namespace)
}
return obj
}
// loadBalancerStatusIPs extracts assigned IPs from a Service's
// status.loadBalancer.ingress (cloud / pool backends report here).
func loadBalancerStatusIPs(svc *corev1.Service) []string {
var ips []string
for _, ing := range svc.Status.LoadBalancer.Ingress {
switch {
case ing.IP != "":
ips = append(ips, ing.IP)
case ing.Hostname != "":
ips = append(ips, ing.Hostname)
}
}
return ips
}
@@ -1,317 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backend
import (
"reflect"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
func boolPtr(b bool) *bool { return &b }
func testService() *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "root-ingress", Namespace: "tenant-root"},
}
}
func metallbClass(addrs ...string) *networkv1alpha1.ExposureClass {
return &networkv1alpha1.ExposureClass{
ObjectMeta: metav1.ObjectMeta{Name: "default"},
Spec: networkv1alpha1.ExposureClassSpec{Backend: networkv1alpha1.BackendMetalLB, Addresses: addrs},
}
}
func ciliumClass(addrs ...string) *networkv1alpha1.ExposureClass {
return &networkv1alpha1.ExposureClass{
ObjectMeta: metav1.ObjectMeta{Name: "default"},
Spec: networkv1alpha1.ExposureClassSpec{Backend: networkv1alpha1.BackendCilium, Addresses: addrs},
}
}
// findObj returns the first object in objs with the given kind, or nil.
func findObj(objs []*unstructured.Unstructured, kind string) *unstructured.Unstructured {
for _, o := range objs {
if o.GetKind() == kind {
return o
}
}
return nil
}
func TestRegistry_AllBackendsRegistered(t *testing.T) {
for _, name := range []networkv1alpha1.ExposureBackend{
networkv1alpha1.BackendExternalIPs,
networkv1alpha1.BackendMetalLB,
networkv1alpha1.BackendCilium,
networkv1alpha1.BackendRobotLB,
} {
b, err := For(name)
if err != nil {
t.Fatalf("For(%q) returned error: %v", name, err)
}
if b.Name() != name {
t.Errorf("For(%q).Name() = %q", name, b.Name())
}
}
if _, err := For("bogus"); err == nil {
t.Error("For(bogus) expected error, got nil")
}
}
func TestExternalIPsBackend(t *testing.T) {
b := externalIPsBackend{}
objs, err := b.Desired(&networkv1alpha1.ExposureClass{}, []string{"tenant-root"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 0 {
t.Errorf("externalIPs backend must render no objects, got %d", len(objs))
}
svc := testService()
svc.Spec.ExternalIPs = []string{"203.0.113.10"}
ips, ready, _ := b.Observe(svc)
if !ready || !reflect.DeepEqual(ips, []string{"203.0.113.10"}) {
t.Errorf("Observe = (%v, %v), want ([203.0.113.10], true)", ips, ready)
}
if _, ready, reason := b.Observe(testService()); ready || reason != "NoExternalIPs" {
t.Errorf("Observe(no IPs) = (%v, %q), want (false, NoExternalIPs)", ready, reason)
}
}
func TestMetalLBBackend_Desired(t *testing.T) {
class := metallbClass("192.0.2.0/24", "198.51.100.10-198.51.100.20")
objs, err := metallbBackend{}.Desired(class, []string{"tenant-root"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 2 {
t.Fatalf("want 2 objects (pool + L2Advertisement), got %d", len(objs))
}
pool := findObj(objs, "IPAddressPool")
if pool == nil {
t.Fatal("no IPAddressPool rendered")
}
if pool.GetAPIVersion() != "metallb.io/v1beta1" {
t.Errorf("pool apiVersion = %q", pool.GetAPIVersion())
}
if pool.GetNamespace() != MetalLBNamespace {
t.Errorf("pool namespace = %q, want %q", pool.GetNamespace(), MetalLBNamespace)
}
if pool.GetName() != "cozystack-default" {
t.Errorf("pool name = %q, want cozystack-default (class-level)", pool.GetName())
}
addrs, _, _ := unstructured.NestedStringSlice(pool.Object, "spec", "addresses")
if !reflect.DeepEqual(addrs, class.Spec.Addresses) {
t.Errorf("pool addresses = %v, want %v", addrs, class.Spec.Addresses)
}
nsAlloc, _, _ := unstructured.NestedStringSlice(pool.Object, "spec", "serviceAllocation", "namespaces")
if !reflect.DeepEqual(nsAlloc, []string{"tenant-root"}) {
t.Errorf("serviceAllocation.namespaces = %v, want [tenant-root]", nsAlloc)
}
l2 := findObj(objs, "L2Advertisement")
if l2 == nil {
t.Fatal("no L2Advertisement rendered")
}
pools, _, _ := unstructured.NestedStringSlice(l2.Object, "spec", "ipAddressPools")
if !reflect.DeepEqual(pools, []string{"cozystack-default"}) {
t.Errorf("l2 ipAddressPools = %v", pools)
}
}
func TestMetalLBBackend_NamespaceUnion(t *testing.T) {
objs, err := metallbBackend{}.Desired(metallbClass("192.0.2.0/24"), []string{"tenant-a", "tenant-b"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
pool := findObj(objs, "IPAddressPool")
nsAlloc, _, _ := unstructured.NestedStringSlice(pool.Object, "spec", "serviceAllocation", "namespaces")
if !reflect.DeepEqual(nsAlloc, []string{"tenant-a", "tenant-b"}) {
t.Errorf("serviceAllocation.namespaces = %v, want [tenant-a tenant-b]", nsAlloc)
}
}
func TestMetalLBBackend_NoL2(t *testing.T) {
class := metallbClass("192.0.2.0/24")
class.Spec.L2 = boolPtr(false)
objs, err := metallbBackend{}.Desired(class, []string{"tenant-root"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 1 || findObj(objs, "IPAddressPool") == nil {
t.Errorf("L2 disabled must render only the pool, got %d objects", len(objs))
}
}
func TestMetalLBBackend_NoNamespacesRendersNothing(t *testing.T) {
objs, err := metallbBackend{}.Desired(metallbClass("192.0.2.0/24"), nil)
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 0 {
t.Errorf("no namespaces must render nothing, got %d objects", len(objs))
}
}
func TestMetalLBBackend_EmptyAddressesErrors(t *testing.T) {
class := &networkv1alpha1.ExposureClass{
ObjectMeta: metav1.ObjectMeta{Name: "default"},
Spec: networkv1alpha1.ExposureClassSpec{Backend: networkv1alpha1.BackendMetalLB},
}
if _, err := (metallbBackend{}).Desired(class, []string{"tenant-root"}); err == nil {
t.Error("metallb with no addresses must error")
}
}
func TestCiliumBackend_Desired(t *testing.T) {
class := ciliumClass("192.0.2.0/24", "198.51.100.10-198.51.100.20")
class.Spec.Interfaces = []string{"eth0"}
objs, err := ciliumBackend{}.Desired(class, []string{"tenant-root"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 2 {
t.Fatalf("want 2 objects, got %d", len(objs))
}
pool := findObj(objs, "CiliumLoadBalancerIPPool")
if pool == nil {
t.Fatal("no CiliumLoadBalancerIPPool rendered")
}
if pool.GetAPIVersion() != "cilium.io/v2" {
t.Errorf("pool apiVersion = %q, want cilium.io/v2", pool.GetAPIVersion())
}
if pool.GetNamespace() != "" {
t.Errorf("CiliumLoadBalancerIPPool must be cluster-scoped, got namespace %q", pool.GetNamespace())
}
if pool.GetName() != "cozystack-default" {
t.Errorf("pool name = %q, want cozystack-default", pool.GetName())
}
blocks, _, _ := unstructured.NestedSlice(pool.Object, "spec", "blocks")
if len(blocks) != 2 {
t.Fatalf("want 2 blocks, got %d", len(blocks))
}
cidrBlock, _ := blocks[0].(map[string]interface{})
if cidrBlock["cidr"] != "192.0.2.0/24" {
t.Errorf("block[0].cidr = %v", cidrBlock["cidr"])
}
rangeBlock, _ := blocks[1].(map[string]interface{})
if rangeBlock["start"] != "198.51.100.10" || rangeBlock["stop"] != "198.51.100.20" {
t.Errorf("block[1] range = %v", rangeBlock)
}
if !ciliumSelectorHasNamespaces(t, pool, []string{"tenant-root"}) {
t.Errorf("pool serviceSelector did not scope to [tenant-root]")
}
l2 := findObj(objs, "CiliumL2AnnouncementPolicy")
if l2 == nil {
t.Fatal("no CiliumL2AnnouncementPolicy rendered")
}
if l2.GetAPIVersion() != "cilium.io/v2alpha1" {
t.Errorf("l2 apiVersion = %q, want cilium.io/v2alpha1", l2.GetAPIVersion())
}
ifaces, _, _ := unstructured.NestedStringSlice(l2.Object, "spec", "interfaces")
if !reflect.DeepEqual(ifaces, []string{"eth0"}) {
t.Errorf("l2 interfaces = %v", ifaces)
}
}
func TestCiliumBackend_NamespaceUnion(t *testing.T) {
objs, err := ciliumBackend{}.Desired(ciliumClass("192.0.2.0/24"), []string{"tenant-a", "tenant-b"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
pool := findObj(objs, "CiliumLoadBalancerIPPool")
if !ciliumSelectorHasNamespaces(t, pool, []string{"tenant-a", "tenant-b"}) {
t.Errorf("pool serviceSelector did not scope to [tenant-a tenant-b]")
}
}
// ciliumSelectorHasNamespaces asserts the pool's serviceSelector contains
// a matchExpressions entry on io.kubernetes.service.namespace In want.
func ciliumSelectorHasNamespaces(t *testing.T, obj *unstructured.Unstructured, want []string) bool {
t.Helper()
exprs, _, _ := unstructured.NestedSlice(obj.Object, "spec", "serviceSelector", "matchExpressions")
for _, e := range exprs {
m, _ := e.(map[string]interface{})
if m["key"] != "io.kubernetes.service.namespace" || m["operator"] != "In" {
continue
}
vals, _, _ := unstructured.NestedStringSlice(m, "values")
if reflect.DeepEqual(vals, want) {
return true
}
}
return false
}
func TestCiliumBackend_NoL2(t *testing.T) {
class := ciliumClass("192.0.2.0/24")
class.Spec.L2 = boolPtr(false)
objs, err := ciliumBackend{}.Desired(class, []string{"tenant-root"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 1 || findObj(objs, "CiliumLoadBalancerIPPool") == nil {
t.Errorf("L2 disabled must render only the pool, got %d objects", len(objs))
}
}
func TestCiliumBackend_NoNamespacesRendersNothing(t *testing.T) {
objs, err := ciliumBackend{}.Desired(ciliumClass("192.0.2.0/24"), nil)
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 0 {
t.Errorf("no namespaces must render nothing, got %d objects", len(objs))
}
}
func TestCiliumBackend_EmptyAddressesErrors(t *testing.T) {
class := &networkv1alpha1.ExposureClass{
ObjectMeta: metav1.ObjectMeta{Name: "default"},
Spec: networkv1alpha1.ExposureClassSpec{Backend: networkv1alpha1.BackendCilium},
}
if _, err := (ciliumBackend{}).Desired(class, []string{"tenant-root"}); err == nil {
t.Error("cilium with no addresses must error")
}
}
func TestRobotLBBackend(t *testing.T) {
objs, err := robotlbBackend{}.Desired(&networkv1alpha1.ExposureClass{}, []string{"tenant-root"})
if err != nil {
t.Fatalf("Desired error: %v", err)
}
if len(objs) != 0 {
t.Errorf("robotlb backend must render no objects, got %d", len(objs))
}
svc := testService()
svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{IP: "203.0.113.55"}}
ips, ready, _ := robotlbBackend{}.Observe(svc)
if !ready || !reflect.DeepEqual(ips, []string{"203.0.113.55"}) {
t.Errorf("Observe = (%v, %v)", ips, ready)
}
}
@@ -1,91 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backend
import (
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// ciliumBackend allocates VIPs from a CiliumLoadBalancerIPPool (LB-IPAM)
// and announces them on L2 via a CiliumL2AnnouncementPolicy. Both CRs are
// cluster-scoped, class-level (one per ExposureClass), and scoped via a
// serviceSelector to LoadBalancer Services in the class's namespaces.
// Note the two CRs use different apiVersions in Cilium 1.19 (pool: v2,
// L2 policy: v2alpha1).
type ciliumBackend struct{}
func (ciliumBackend) Name() networkv1alpha1.ExposureBackend {
return networkv1alpha1.BackendCilium
}
func (ciliumBackend) Desired(
class *networkv1alpha1.ExposureClass,
namespaces []string,
) ([]*unstructured.Unstructured, error) {
// No live exposures ⇒ nothing to render (and the controller GCs the
// class's CRs). Check this BEFORE validating addresses so that deleting
// the last exposure of a misconfigured (address-less) class still
// converges to an empty desired set instead of erroring.
if len(namespaces) == 0 {
return nil, nil
}
if len(class.Spec.Addresses) == 0 {
return nil, fmt.Errorf("cilium backend requires exposureClass %q to set spec.addresses", class.Name)
}
blocks := ciliumBlocks(class.Spec.Addresses)
if len(blocks) == 0 {
return nil, fmt.Errorf("cilium backend: exposureClass %q has no usable addresses", class.Name)
}
name := PoolName(class.Name)
selector := namespaceServiceSelector(namespaces)
pool := newUnstructured(ciliumPoolAPIVersion, "CiliumLoadBalancerIPPool", "", name, map[string]interface{}{
"blocks": blocks,
"serviceSelector": selector,
})
objs := []*unstructured.Unstructured{pool}
if l2Enabled(class) {
l2Spec := map[string]interface{}{
"loadBalancerIPs": true,
"serviceSelector": selector,
}
if len(class.Spec.Interfaces) > 0 {
l2Spec["interfaces"] = toIfaceSlice(class.Spec.Interfaces)
}
l2 := newUnstructured(ciliumL2APIVersion, "CiliumL2AnnouncementPolicy", "", name, l2Spec)
objs = append(objs, l2)
}
return objs, nil
}
func (ciliumBackend) Observe(svc *corev1.Service) (assignedIPs []string, ready bool, reason string) {
ips := loadBalancerStatusIPs(svc)
if len(ips) > 0 {
return ips, true, ""
}
return nil, false, "AwaitingAllocation"
}
func init() { register(ciliumBackend{}) }
@@ -1,53 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backend
import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// externalIPsBackend pins the Service to operator-supplied addresses via
// Service.spec.externalIPs. The node already owns the address, so there
// is no pool to allocate and no announcer to render — this backend
// produces zero cluster resources and exists to preserve the historical
// default behaviour while keeping the API uniform across backends. The
// Service's own spec.externalIPs is set by the chart that renders it, not
// by this controller.
type externalIPsBackend struct{}
func (externalIPsBackend) Name() networkv1alpha1.ExposureBackend {
return networkv1alpha1.BackendExternalIPs
}
func (externalIPsBackend) Desired(
_ *networkv1alpha1.ExposureClass,
_ []string,
) ([]*unstructured.Unstructured, error) {
return nil, nil
}
func (externalIPsBackend) Observe(svc *corev1.Service) (assignedIPs []string, ready bool, reason string) {
if len(svc.Spec.ExternalIPs) > 0 {
return append([]string(nil), svc.Spec.ExternalIPs...), true, ""
}
return nil, false, "NoExternalIPs"
}
func init() { register(externalIPsBackend{}) }
@@ -1,89 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backend
import (
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// metallbBackend allocates VIPs from a metallb.io IPAddressPool and
// announces them on L2 via an L2Advertisement. Both CRs are class-level
// (one per ExposureClass) and live in the cozy-metallb namespace (where
// the metallb chart installs). The pool is scoped via serviceAllocation
// to the class's namespaces, so it is not cluster-wide.
type metallbBackend struct{}
func (metallbBackend) Name() networkv1alpha1.ExposureBackend {
return networkv1alpha1.BackendMetalLB
}
func (metallbBackend) Desired(
class *networkv1alpha1.ExposureClass,
namespaces []string,
) ([]*unstructured.Unstructured, error) {
// No live exposures ⇒ nothing to render (and the controller GCs the
// class's CRs). Check this BEFORE validating addresses so that deleting
// the last exposure of a misconfigured (address-less) class still
// converges to an empty desired set instead of erroring.
if len(namespaces) == 0 {
return nil, nil
}
if len(class.Spec.Addresses) == 0 {
return nil, fmt.Errorf("metallb backend requires exposureClass %q to set spec.addresses", class.Name)
}
name := PoolName(class.Name)
pool := newUnstructured(metallbAPIVersion, "IPAddressPool", MetalLBNamespace, name, map[string]interface{}{
"addresses": toIfaceSlice(class.Spec.Addresses),
// Scope allocation to the class's namespaces so this pool only
// serves LoadBalancer Services in namespaces that opted in via a
// ServiceExposure — symmetric with the Cilium backend.
"serviceAllocation": map[string]interface{}{
"namespaces": toIfaceSlice(namespaces),
},
})
objs := []*unstructured.Unstructured{pool}
if l2Enabled(class) {
l2Spec := map[string]interface{}{
"ipAddressPools": []interface{}{name},
}
if len(class.Spec.Interfaces) > 0 {
l2Spec["interfaces"] = toIfaceSlice(class.Spec.Interfaces)
}
l2 := newUnstructured(metallbAPIVersion, "L2Advertisement", MetalLBNamespace, name, l2Spec)
objs = append(objs, l2)
}
return objs, nil
}
func (metallbBackend) Observe(svc *corev1.Service) (assignedIPs []string, ready bool, reason string) {
ips := loadBalancerStatusIPs(svc)
if len(ips) > 0 {
return ips, true, ""
}
return nil, false, "AwaitingAllocation"
}
func init() { register(metallbBackend{}) }
@@ -1,44 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backend
import (
"fmt"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// registry maps a backend enum value to its implementation. Each backend
// registers itself from an init() in its own file, so the set of
// supported backends is the single extension point.
var registry = map[networkv1alpha1.ExposureBackend]Backend{}
// register adds a backend to the registry. Called from backend init().
func register(b Backend) {
registry[b.Name()] = b
}
// For returns the backend implementing the given enum value, or an error
// if none is registered (an unknown backend value reaching here is a bug
// or an unsupported class).
func For(name networkv1alpha1.ExposureBackend) (Backend, error) {
b, ok := registry[name]
if !ok {
return nil, fmt.Errorf("no backend registered for %q", name)
}
return b, nil
}
@@ -1,51 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backend
import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// robotlbBackend delegates allocation and announcement to a cloud load
// balancer (Hetzner robotlb watches LoadBalancer Services and provisions
// a Hetzner Cloud LB). The controller renders no in-cluster pool or
// announcer; it only reports the cloud-assigned IP back into status.
type robotlbBackend struct{}
func (robotlbBackend) Name() networkv1alpha1.ExposureBackend {
return networkv1alpha1.BackendRobotLB
}
func (robotlbBackend) Desired(
_ *networkv1alpha1.ExposureClass,
_ []string,
) ([]*unstructured.Unstructured, error) {
return nil, nil
}
func (robotlbBackend) Observe(svc *corev1.Service) (assignedIPs []string, ready bool, reason string) {
ips := loadBalancerStatusIPs(svc)
if len(ips) > 0 {
return ips, true, ""
}
return nil, false, "AwaitingCloudLB"
}
func init() { register(robotlbBackend{}) }
@@ -1,82 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package serviceexposure
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// +kubebuilder:rbac:groups=network.cozystack.io,resources=exposureclasses/finalizers,verbs=update
// ClassReconciler owns the lifecycle cleanup of an ExposureClass. The
// class-level pool/announcer CRs carry no OwnerReference (they are
// cross-scope), so when an ExposureClass is deleted nothing else reclaims
// them — this reconciler does, via a finalizer. It does not render the
// pool; that stays driven by ServiceExposure reconciles, which know the
// namespace set.
//
// Eventual-consistency note: a ServiceExposure reconcile running against a
// stale (pre-deletionTimestamp) cached class could recreate the pool this
// controller just GC'd. reconcileClass guards against this by skipping a
// terminating class; once the watch cache catches up, both controllers
// agree. An OwnerReference would make this atomic, but is impossible here
// (the pool is cluster-scoped / cross-namespace relative to the class).
type ClassReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *ClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
class := &networkv1alpha1.ExposureClass{}
if err := r.Get(ctx, req.NamespacedName, class); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !class.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(class, exposureFinalizer) {
if err := reclaimClassResources(ctx, r.Client, class.Name, map[objectKey]struct{}{}); err != nil {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(class, exposureFinalizer)
if err := r.Update(ctx, class); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
}
return ctrl.Result{}, nil
}
if controllerutil.AddFinalizer(class, exposureFinalizer) {
if err := r.Update(ctx, class); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
}
return ctrl.Result{}, nil
}
func (r *ClassReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("exposureclass-controller").
For(&networkv1alpha1.ExposureClass{}).
Complete(r)
}
@@ -1,117 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package serviceexposure
import (
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
// managedByLabel / managedByValue mark every CR this controller
// renders. Reused for the take-over guard and garbage collection.
// The value is specific to this controller (distinct from the shared
// cozystack-controller binary) so `kubectl get -l` triage is clear.
managedByLabel = "cozystack.io/managed-by"
managedByValue = "serviceexposure-controller"
// ownerClassAnnotation records which ExposureClass owns a rendered CR.
// The pool and announcer are class-level (one per class), so ownership
// is by class, not by individual ServiceExposure. OwnerReferences
// cannot be used: the Cilium CRs are cluster-scoped and the MetalLB CRs
// live in cozy-metallb, while the class is cluster-scoped — so a real
// ownerRef back to a namespaced ServiceExposure is impossible anyway.
ownerClassAnnotation = "serviceexposure.network.cozystack.io/owner-class"
// exposureFinalizer guards cross-scope cleanup: because the rendered
// CRs carry no ownerRef, Kubernetes garbage collection will not remove
// them when the last ServiceExposure of a class is deleted. The
// controller does it.
exposureFinalizer = "network.cozystack.io/serviceexposure-cleanup"
)
// stamp adds the management label and owner-class annotation to a CR.
func stamp(obj *unstructured.Unstructured, className string) {
labels := obj.GetLabels()
if labels == nil {
labels = map[string]string{}
}
labels[managedByLabel] = managedByValue
obj.SetLabels(labels)
ann := obj.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
ann[ownerClassAnnotation] = className
obj.SetAnnotations(ann)
}
// managedByController reports whether a CR carries this controller's
// management label. A same-named CR lacking it is operator-owned and must
// not be taken over.
func managedByController(obj *unstructured.Unstructured) bool {
return obj.GetLabels()[managedByLabel] == managedByValue
}
// ownedByClass reports whether a managed CR belongs to the given class.
func ownedByClass(obj *unstructured.Unstructured, className string) bool {
if !managedByController(obj) {
return false
}
return obj.GetAnnotations()[ownerClassAnnotation] == className
}
// objectKey is a stable identity for a rendered CR across reconciles.
type objectKey struct {
apiVersion string
kind string
namespace string
name string
}
func keyOf(obj *unstructured.Unstructured) objectKey {
return objectKey{
apiVersion: obj.GetAPIVersion(),
kind: obj.GetKind(),
namespace: obj.GetNamespace(),
name: obj.GetName(),
}
}
// backendCRDMissing reports whether an API error means the backend's CRD
// is not installed in the cluster (or not registered in a test scheme),
// so the controller can surface a clear BackendUnavailable reason instead
// of a generic failure. Covers both the live-cluster RESTMapper miss and
// the scheme miss the fake client raises.
func backendCRDMissing(err error) bool {
return meta.IsNoMatchError(err) || runtime.IsNotRegisteredError(err)
}
// managedListFor returns an empty UnstructuredList typed to the List GVK
// of the given managed kind, ready for a labelled List call.
func managedListFor(gvk schema.GroupVersionKind) *unstructured.UnstructuredList {
list := &unstructured.UnstructuredList{}
list.SetGroupVersionKind(schema.GroupVersionKind{
Group: gvk.Group,
Version: gvk.Version,
Kind: gvk.Kind + "List",
})
return list
}
@@ -1,100 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package serviceexposure
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// serviceToExposure maps a Service change back to the ServiceExposure(s)
// in the same namespace that reference it, so a freshly-assigned
// LoadBalancer IP triggers a status refresh.
func (r *Reconciler) serviceToExposure() handler.EventHandler {
return handler.EnqueueRequestsFromMapFunc(r.mapServiceToExposures)
}
func (r *Reconciler) mapServiceToExposures(ctx context.Context, obj client.Object) []reconcile.Request {
svc, ok := obj.(*corev1.Service)
if !ok {
return nil
}
list := &networkv1alpha1.ServiceExposureList{}
if err := r.List(ctx, list, client.InNamespace(svc.Namespace)); err != nil {
log.FromContext(ctx).Error(err, "list ServiceExposures for service mapper")
return nil
}
var out []reconcile.Request
for i := range list.Items {
exp := &list.Items[i]
if exp.Spec.ServiceRef.Name != svc.Name {
continue
}
out = append(out, reconcile.Request{NamespacedName: types.NamespacedName{
Namespace: exp.Namespace, Name: exp.Name,
}})
}
return out
}
// classToExposures maps an ExposureClass change back to every
// ServiceExposure that resolves to it — those naming it explicitly, plus
// every empty-class exposure when the changed class is the cluster default.
func (r *Reconciler) classToExposures() handler.EventHandler {
return handler.EnqueueRequestsFromMapFunc(r.mapClassToExposures)
}
func (r *Reconciler) mapClassToExposures(ctx context.Context, obj client.Object) []reconcile.Request {
class, ok := obj.(*networkv1alpha1.ExposureClass)
if !ok {
return nil
}
isDefault := class.Annotations[networkv1alpha1.IsDefaultExposureClassAnnotation] == "true"
list := &networkv1alpha1.ServiceExposureList{}
if err := r.List(ctx, list); err != nil {
log.FromContext(ctx).Error(err, "list ServiceExposures for class mapper")
return nil
}
var out []reconcile.Request
for i := range list.Items {
exp := &list.Items[i]
if !exposureReferencesClass(exp, class.Name, isDefault) {
continue
}
out = append(out, reconcile.Request{NamespacedName: types.NamespacedName{
Namespace: exp.Namespace, Name: exp.Name,
}})
}
return out
}
// exposureReferencesClass reports whether the exposure resolves to the
// named class — either by naming it explicitly, or by leaving the class
// name empty when that class is the cluster default.
func exposureReferencesClass(exp *networkv1alpha1.ServiceExposure, className string, classIsDefault bool) bool {
name := exp.Spec.ExposureClassName
return name == className || (name == "" && classIsDefault)
}
@@ -1,73 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package serviceexposure
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
func TestMapServiceToExposures(t *testing.T) {
s := newScheme(t)
match := exposure("") // serviceRef root-ingress in tenant-root
other := exposure("") // same, but we mutate name/ref below
other.Name = "other"
other.Spec.ServiceRef.Name = "different-svc"
c := fake.NewClientBuilder().WithScheme(s).WithObjects(match, other).Build()
r := &Reconciler{Client: c, Scheme: s}
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "root-ingress", Namespace: "tenant-root"}}
reqs := r.mapServiceToExposures(context.Background(), svc)
if len(reqs) != 1 || reqs[0].Name != "root-ingress" {
t.Fatalf("want 1 request for root-ingress, got %v", reqs)
}
}
func TestMapClassToExposures_NamedAndDefault(t *testing.T) {
s := newScheme(t)
named := exposure("gold")
named.Name = "named"
usesDefault := exposure("") // empty ⇒ resolves to default
usesDefault.Name = "uses-default"
usesOther := exposure("silver")
usesOther.Name = "uses-other"
c := fake.NewClientBuilder().WithScheme(s).WithObjects(named, usesDefault, usesOther).Build()
r := &Reconciler{Client: c, Scheme: s}
// A change to the "gold" class (not default) hits only the named one.
gold := &networkv1alpha1.ExposureClass{ObjectMeta: metav1.ObjectMeta{Name: "gold"}}
reqs := r.mapClassToExposures(context.Background(), gold)
if len(reqs) != 1 || reqs[0].Name != "named" {
t.Fatalf("gold class should map to [named], got %v", reqs)
}
// A change to the default class hits every empty-class exposure.
def := &networkv1alpha1.ExposureClass{ObjectMeta: metav1.ObjectMeta{
Name: "default",
Annotations: map[string]string{networkv1alpha1.IsDefaultExposureClassAnnotation: "true"},
}}
reqs = r.mapClassToExposures(context.Background(), def)
if len(reqs) != 1 || reqs[0].Name != "uses-default" {
t.Fatalf("default class should map to [uses-default], got %v", reqs)
}
}
@@ -1,395 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package serviceexposure hosts the controller that reconciles
// network.cozystack.io/v1alpha1 ServiceExposure resources into the
// backend-specific LoadBalancer pool and announcer CRs (MetalLB
// IPAddressPool + L2Advertisement, Cilium CiliumLoadBalancerIPPool +
// CiliumL2AnnouncementPolicy, or nothing for externalIPs / robotlb),
// resolving the requested ExposureClass to a concrete backend.
//
// Charts render ServiceExposure CRs; this controller owns everything
// downstream. It deliberately never mutates the target Service — that
// object is owned by the chart that renders it, so mutating it would
// create a Helm-vs-controller drift loop. The backend pool/announcer CRs
// are scoped to the Service via the pool CR's own selector instead.
package serviceexposure
import (
"context"
"fmt"
"sort"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
"github.com/cozystack/cozystack/internal/controller/serviceexposure/backend"
)
// +kubebuilder:rbac:groups=network.cozystack.io,resources=serviceexposures;exposureclasses,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=network.cozystack.io,resources=serviceexposures/status;exposureclasses/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=network.cozystack.io,resources=serviceexposures/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch
// +kubebuilder:rbac:groups=metallb.io,resources=ipaddresspools;l2advertisements,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=cilium.io,resources=ciliumloadbalancerippools;ciliuml2announcementpolicies,verbs=get;list;watch;create;update;patch;delete
// Reconciler reconciles ServiceExposure resources, owning the downstream
// backend pool/announcer CRs.
type Reconciler struct {
client.Client
Scheme *runtime.Scheme
}
// Reconcile resolves the ServiceExposure's ExposureClass, reconciles the
// class-level backend pool/announcer (shared by every exposure of the
// class, scoped to their namespaces), then reports the assigned IPs for
// this exposure's Service in status.
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
exp := &networkv1alpha1.ServiceExposure{}
if err := r.Get(ctx, req.NamespacedName, exp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
class, err := r.resolveClass(ctx, exp)
// Deletion: recompute the class without this exposure (it is excluded
// from the namespace set because it is being deleted), which updates or
// garbage-collects the shared pool, then drop the finalizer.
if !exp.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(exp, exposureFinalizer) {
switch {
case class != nil:
if rcErr := r.reconcileClass(ctx, class); rcErr != nil {
// A transient error requeues so the shared pool is still
// recomputed before the exposure goes away. Only the two
// deterministic, config-driven reasons fall through:
// BackendRenderError (e.g. addresses cleared) and the
// defensive BackendUnsupported (unreachable today — the
// CRD enum is pinned to registered backends — but treated
// the same so an enum/registry drift cannot wedge deletes).
if reason := reasonOf(rcErr); reason != "BackendRenderError" && reason != "BackendUnsupported" {
return ctrl.Result{}, rcErr
}
// Deterministic render error: the class can't produce a
// valid pool (e.g. addresses cleared while exposures are
// live). A pool created when the class WAS valid is now
// frozen with a stale serviceAllocation/serviceSelector
// that would keep this deleted namespace in scope — an
// isolation leak. Reclaim the class's CRs entirely rather
// than drop the finalizer over a stale pool; a surviving
// exposure recreates a correctly-scoped pool once the
// class is fixed. Then the finalizer can be removed.
if gcErr := reclaimClassResources(ctx, r.Client, class.Name, map[objectKey]struct{}{}); gcErr != nil {
return ctrl.Result{}, gcErr
}
log.FromContext(ctx).Info("reclaimed CRs of a misconfigured class during exposure deletion", "class", class.Name, "reason", reasonOf(rcErr))
}
case err != nil && reasonOf(err) != "ClassNotFound":
// A transient resolveClass error (apiserver hiccup) — or a
// config error like AmbiguousDefaultClass — must NOT drop
// the finalizer: doing so would delete the exposure without
// recomputing the shared pool, leaking it or leaving a
// stale namespace in its selector. Requeue, and log so an
// operator can see why a delete is not progressing.
log.FromContext(ctx).Info("deferring ServiceExposure deletion until its class resolves", "reason", reasonOf(err), "error", err.Error())
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(exp, exposureFinalizer)
if err := r.Update(ctx, exp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
}
return ctrl.Result{}, nil
}
if controllerutil.AddFinalizer(exp, exposureFinalizer) {
if err := r.Update(ctx, exp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
}
if err := r.runReconcileSteps(ctx, exp, class, err); err != nil {
if statusErr := r.markFailed(ctx, exp, err); statusErr != nil {
return ctrl.Result{}, fmt.Errorf("reconcile failed: %w (status update also failed: %v)", err, statusErr)
}
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *Reconciler) runReconcileSteps(ctx context.Context, exp *networkv1alpha1.ServiceExposure, class *networkv1alpha1.ExposureClass, classErr error) error {
if classErr != nil {
return classErr
}
if err := r.reconcileClass(ctx, class); err != nil {
return err
}
b, err := backend.For(class.Spec.Backend)
if err != nil {
return failf("BackendUnsupported", err)
}
svc := &corev1.Service{}
svcName := types.NamespacedName{Namespace: exp.Namespace, Name: exp.Spec.ServiceRef.Name}
if err := r.Get(ctx, svcName, svc); err != nil {
if apierrors.IsNotFound(err) {
return failf("ServiceNotFound", fmt.Errorf("service %s not found", svcName))
}
return fmt.Errorf("get service %s: %w", svcName, err)
}
assignedIPs, ready, notReady := b.Observe(svc)
return r.markReady(ctx, exp, string(class.Spec.Backend), assignedIPs, ready, notReady)
}
// reconcileClass renders the class-level pool/announcer scoped to the
// namespaces of every live (non-deleting) ServiceExposure resolving to the
// class, and garbage-collects the class's CRs no longer wanted (including
// all of them when the last exposure goes away or the backend changes).
func (r *Reconciler) reconcileClass(ctx context.Context, class *networkv1alpha1.ExposureClass) error {
// A terminating class is cleaned up by the ExposureClass controller;
// don't re-render its pool here or the two would race.
if !class.DeletionTimestamp.IsZero() {
return nil
}
b, err := backend.For(class.Spec.Backend)
if err != nil {
return failf("BackendUnsupported", err)
}
namespaces, err := r.namespacesForClass(ctx, class)
if err != nil {
return err
}
desired, err := b.Desired(class, namespaces)
if err != nil {
return failf("BackendRenderError", err)
}
return r.applyDesired(ctx, class.Name, desired)
}
// namespacesForClass returns the sorted, unique namespaces of every
// non-deleting ServiceExposure that resolves to the class.
func (r *Reconciler) namespacesForClass(ctx context.Context, class *networkv1alpha1.ExposureClass) ([]string, error) {
isDefault := class.Annotations[networkv1alpha1.IsDefaultExposureClassAnnotation] == "true"
list := &networkv1alpha1.ServiceExposureList{}
if err := r.List(ctx, list); err != nil {
return nil, fmt.Errorf("list ServiceExposures for class %q: %w", class.Name, err)
}
seen := map[string]struct{}{}
for i := range list.Items {
e := &list.Items[i]
if !e.DeletionTimestamp.IsZero() {
continue
}
if !exposureReferencesClass(e, class.Name, isDefault) {
continue
}
seen[e.Namespace] = struct{}{}
}
namespaces := make([]string, 0, len(seen))
for ns := range seen {
namespaces = append(namespaces, ns)
}
sort.Strings(namespaces)
return namespaces, nil
}
// resolveClass returns the named ExposureClass, or the cluster default
// (annotated is-default-class=true) when the exposure leaves the name
// empty.
func (r *Reconciler) resolveClass(ctx context.Context, exp *networkv1alpha1.ServiceExposure) (*networkv1alpha1.ExposureClass, error) {
if name := exp.Spec.ExposureClassName; name != "" {
class := &networkv1alpha1.ExposureClass{}
if err := r.Get(ctx, types.NamespacedName{Name: name}, class); err != nil {
if apierrors.IsNotFound(err) {
return nil, failf("ClassNotFound", fmt.Errorf("exposureClass %q not found", name))
}
return nil, fmt.Errorf("get exposureClass %q: %w", name, err)
}
return class, nil
}
list := &networkv1alpha1.ExposureClassList{}
if err := r.List(ctx, list); err != nil {
return nil, fmt.Errorf("list exposureClasses: %w", err)
}
var defaults []*networkv1alpha1.ExposureClass
for i := range list.Items {
if list.Items[i].Annotations[networkv1alpha1.IsDefaultExposureClassAnnotation] == "true" {
defaults = append(defaults, &list.Items[i])
}
}
switch len(defaults) {
case 0:
return nil, failf("ClassNotFound", fmt.Errorf("no exposureClassName set and no default ExposureClass found"))
case 1:
return defaults[0], nil
default:
return nil, failf("AmbiguousDefaultClass", fmt.Errorf("found %d default ExposureClasses, expected at most one", len(defaults)))
}
}
// applyDesired creates/updates the class-level desired CRs and
// garbage-collects any previously-owned CRs of the class no longer in the
// desired set (a backend change, or the last exposure going away, yields
// an empty desired set and reclaims everything).
func (r *Reconciler) applyDesired(ctx context.Context, className string, desired []*unstructured.Unstructured) error {
wanted := make(map[objectKey]struct{}, len(desired))
for _, obj := range desired {
stamp(obj, className)
wanted[keyOf(obj)] = struct{}{}
if err := r.applyObject(ctx, className, obj); err != nil {
return err
}
}
return r.garbageCollect(ctx, className, wanted)
}
// applyObject creates the CR or updates its spec, refusing to take over a
// same-named object this controller does not manage.
func (r *Reconciler) applyObject(ctx context.Context, className string, desired *unstructured.Unstructured) error {
logger := log.FromContext(ctx)
existing := &unstructured.Unstructured{}
existing.SetGroupVersionKind(desired.GroupVersionKind())
err := r.Get(ctx, types.NamespacedName{Namespace: desired.GetNamespace(), Name: desired.GetName()}, existing)
switch {
case apierrors.IsNotFound(err):
if err := r.Create(ctx, desired); err != nil {
if backendCRDMissing(err) {
return failf("BackendUnavailable", fmt.Errorf("%s CRD not installed for the configured backend (resource %s): %w", desired.GetKind(), desired.GetName(), err))
}
return fmt.Errorf("create %s %s: %w", desired.GetKind(), desired.GetName(), err)
}
logger.V(1).Info("created backend resource", "kind", desired.GetKind(), "name", desired.GetName(), "namespace", desired.GetNamespace())
return nil
case backendCRDMissing(err):
// On a live cluster a missing backend CRD surfaces here (the
// RESTMapper cannot map the GVK), not on Create.
return failf("BackendUnavailable", fmt.Errorf("%s CRD not installed for the configured backend (resource %s): %w", desired.GetKind(), desired.GetName(), err))
case err != nil:
return fmt.Errorf("get %s %s: %w", desired.GetKind(), desired.GetName(), err)
}
if !ownedByClass(existing, className) {
return failf("ResourceConflict", fmt.Errorf("%s %s exists but is not managed by ExposureClass %q; refusing to take it over", desired.GetKind(), desired.GetName(), className))
}
// Reconcile only the keys this controller manages (backend.ManagedSpecKeys).
// The apiserver adds CRD-defaulted keys (metallb IPAddressPool
// autoAssign/avoidBuggyIPs, cilium pool disabled/allowFirstLastIPs) the
// backend never sets; a whole-spec DeepEqual would always differ,
// rewrite spec every reconcile, and transiently strip those defaults.
// We add/update managed keys and DELETE managed keys absent from the
// fresh render (so clearing class.spec.interfaces propagates), while
// leaving server-defaulted keys intact.
desiredSpec, _, _ := unstructured.NestedMap(desired.Object, "spec")
existingSpec, _, _ := unstructured.NestedMap(existing.Object, "spec")
if existingSpec == nil {
existingSpec = map[string]interface{}{}
}
changed := false
for _, k := range backend.ManagedSpecKeys(desired.GetKind()) {
dv, want := desiredSpec[k]
ev, have := existingSpec[k]
switch {
case want && (!have || !equality.Semantic.DeepEqual(ev, dv)):
existingSpec[k] = dv
changed = true
case !want && have:
delete(existingSpec, k)
changed = true
}
}
if !changed {
return nil
}
existing.Object["spec"] = existingSpec
stamp(existing, className)
if err := r.Update(ctx, existing); err != nil {
return fmt.Errorf("update %s %s: %w", desired.GetKind(), desired.GetName(), err)
}
logger.V(1).Info("updated backend resource", "kind", desired.GetKind(), "name", desired.GetName())
return nil
}
// garbageCollect deletes CRs owned by the class that are no longer wanted.
func (r *Reconciler) garbageCollect(ctx context.Context, className string, wanted map[objectKey]struct{}) error {
return reclaimClassResources(ctx, r.Client, className, wanted)
}
// reclaimClassResources deletes every managed CR owned by the class that is
// not in the wanted set. An empty wanted set reclaims them all (used when
// the last exposure of the class goes away, the backend changes, or the
// ExposureClass itself is deleted). Shared by the exposure and class
// reconcilers.
func reclaimClassResources(ctx context.Context, c client.Client, className string, wanted map[objectKey]struct{}) error {
logger := log.FromContext(ctx)
for _, gvk := range backend.ManagedGVKs() {
list := managedListFor(gvk)
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)
}
for i := range list.Items {
obj := &list.Items[i]
if !ownedByClass(obj, className) {
continue
}
if _, keep := wanted[keyOf(obj)]; keep {
continue
}
if err := c.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("delete orphan %s %s: %w", obj.GetKind(), obj.GetName(), err)
}
logger.V(1).Info("garbage-collected backend resource", "kind", obj.GetKind(), "name", obj.GetName())
}
}
return nil
}
// SetupWithManager wires the controller. The target Service is watched
// (not Owned — the controller never sets an ownerRef on it) so a freshly
// assigned LoadBalancer IP refreshes status; ExposureClass edits re-sync
// every referencing exposure.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("serviceexposure-controller").
For(&networkv1alpha1.ServiceExposure{}).
Watches(&corev1.Service{}, r.serviceToExposure()).
Watches(&networkv1alpha1.ExposureClass{}, r.classToExposures()).
Complete(r)
}
@@ -1,740 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package serviceexposure
import (
"context"
"testing"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
"github.com/cozystack/cozystack/internal/controller/serviceexposure/backend"
)
func newScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := networkv1alpha1.AddToScheme(s); err != nil {
t.Fatal(err)
}
for _, gvk := range backend.ManagedGVKs() {
s.AddKnownTypeWithName(gvk, &unstructured.Unstructured{})
listGVK := gvk
listGVK.Kind += "List"
s.AddKnownTypeWithName(listGVK, &unstructured.UnstructuredList{})
}
return s
}
func defaultClass(backendName networkv1alpha1.ExposureBackend, addrs ...string) *networkv1alpha1.ExposureClass {
return &networkv1alpha1.ExposureClass{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Annotations: map[string]string{networkv1alpha1.IsDefaultExposureClassAnnotation: "true"},
},
Spec: networkv1alpha1.ExposureClassSpec{Backend: backendName, Addresses: addrs},
}
}
func ingressService() *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "root-ingress", Namespace: "tenant-root"},
Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer},
}
}
func exposure(className string) *networkv1alpha1.ServiceExposure {
return &networkv1alpha1.ServiceExposure{
ObjectMeta: metav1.ObjectMeta{Name: "root-ingress", Namespace: "tenant-root"},
Spec: networkv1alpha1.ServiceExposureSpec{
ServiceRef: corev1.LocalObjectReference{Name: "root-ingress"},
ExposureClassName: className,
},
}
}
// reconcileOnce drives a single Reconcile (which adds the finalizer and
// runs the desired-state work in one pass) and returns its error. Negative
// paths legitimately return an error to trigger requeue while still
// writing status, so callers assert on status rather than on the error.
func reconcileOnce(r *Reconciler, exp *networkv1alpha1.ServiceExposure) error {
key := types.NamespacedName{Namespace: exp.Namespace, Name: exp.Name}
_, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: key})
return err
}
func getExposure(t *testing.T, c client.Client, ns, name string) *networkv1alpha1.ServiceExposure {
t.Helper()
exp := &networkv1alpha1.ServiceExposure{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: name}, exp); err != nil {
t.Fatalf("get exposure: %v", err)
}
return exp
}
func readyCondition(exp *networkv1alpha1.ServiceExposure) *metav1.Condition {
return apimeta.FindStatusCondition(exp.Status.Conditions, "Ready")
}
func getUnstructured(t *testing.T, c client.Client, gvk, name, namespace string) (*unstructured.Unstructured, error) {
t.Helper()
obj := &unstructured.Unstructured{}
parts := splitGVK(gvk)
obj.SetGroupVersionKind(parts)
err := c.Get(context.Background(), types.NamespacedName{Namespace: namespace, Name: name}, obj)
return obj, err
}
// splitGVK maps a test shorthand to a GroupVersionKind.
func splitGVK(s string) schema.GroupVersionKind {
switch s {
case "metallb/IPAddressPool":
return schema.GroupVersionKind{Group: "metallb.io", Version: "v1beta1", Kind: "IPAddressPool"}
case "metallb/L2Advertisement":
return schema.GroupVersionKind{Group: "metallb.io", Version: "v1beta1", Kind: "L2Advertisement"}
case "cilium/Pool":
return schema.GroupVersionKind{Group: "cilium.io", Version: "v2", Kind: "CiliumLoadBalancerIPPool"}
case "cilium/L2":
return schema.GroupVersionKind{Group: "cilium.io", Version: "v2alpha1", Kind: "CiliumL2AnnouncementPolicy"}
}
return schema.GroupVersionKind{}
}
func TestReconcile_ClassNotFound(t *testing.T) {
s := newScheme(t)
exp := exposure("") // no class, no default exists
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService()).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
got := getExposure(t, c, "tenant-root", "root-ingress")
cond := readyCondition(got)
if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != "ClassNotFound" {
t.Fatalf("want Ready=False/ClassNotFound, got %+v", cond)
}
}
func TestReconcile_ServiceNotFound(t *testing.T) {
s := newScheme(t)
exp := exposure("")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
cond := readyCondition(getExposure(t, c, "tenant-root", "root-ingress"))
if cond == nil || cond.Reason != "ServiceNotFound" {
t.Fatalf("want Ready=False/ServiceNotFound, got %+v", cond)
}
}
func TestReconcile_MetalLBHappyPath(t *testing.T) {
s := newScheme(t)
exp := exposure("")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService(), defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
pool, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb")
if err != nil {
t.Fatalf("IPAddressPool not created: %v", err)
}
if pool.GetLabels()[managedByLabel] != managedByValue {
t.Errorf("pool missing managed-by label")
}
if pool.GetAnnotations()[ownerClassAnnotation] != "default" {
t.Errorf("pool missing owner-class annotation")
}
if _, err := getUnstructured(t, c, "metallb/L2Advertisement", "cozystack-default", "cozy-metallb"); err != nil {
t.Fatalf("L2Advertisement not created: %v", err)
}
got := getExposure(t, c, "tenant-root", "root-ingress")
if got.Status.ResolvedBackend != "metallb" {
t.Errorf("resolvedBackend = %q", got.Status.ResolvedBackend)
}
cond := readyCondition(got)
if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != "AwaitingAllocation" {
t.Fatalf("want Ready=False/AwaitingAllocation before IP lands, got %+v", cond)
}
// IP lands → Ready=True, AssignedIPs populated.
svc := ingressService()
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}, svc); err != nil {
t.Fatal(err)
}
svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{IP: "192.0.2.5"}}
if err := c.Status().Update(context.Background(), svc); err != nil {
t.Fatal(err)
}
_ = reconcileOnce(r, exp)
got = getExposure(t, c, "tenant-root", "root-ingress")
cond = readyCondition(got)
if cond == nil || cond.Status != metav1.ConditionTrue {
t.Fatalf("want Ready=True after IP lands, got %+v", cond)
}
if len(got.Status.AssignedIPs) != 1 || got.Status.AssignedIPs[0] != "192.0.2.5" {
t.Errorf("assignedIPs = %v", got.Status.AssignedIPs)
}
}
func TestReconcile_ExternalIPsRendersNoObjects(t *testing.T) {
s := newScheme(t)
exp := exposure("")
svc := ingressService()
svc.Spec.Type = corev1.ServiceTypeClusterIP
svc.Spec.ExternalIPs = []string{"203.0.113.10"}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, svc, defaultClass(networkv1alpha1.BackendExternalIPs)).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); !apierrors.IsNotFound(err) {
t.Errorf("externalIPs backend must render no IPAddressPool, got err=%v", err)
}
cond := readyCondition(getExposure(t, c, "tenant-root", "root-ingress"))
if cond == nil || cond.Status != metav1.ConditionTrue {
t.Fatalf("externalIPs with pinned IPs should be Ready=True, got %+v", cond)
}
}
func TestReconcile_BackendSwitchGCsOldCRs(t *testing.T) {
s := newScheme(t)
exp := exposure("")
class := defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService(), class).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); err != nil {
t.Fatalf("metallb pool should exist: %v", err)
}
// Flip the class to cilium and reconcile.
cur := &networkv1alpha1.ExposureClass{}
if err := c.Get(context.Background(), types.NamespacedName{Name: "default"}, cur); err != nil {
t.Fatal(err)
}
cur.Spec.Backend = networkv1alpha1.BackendCilium
cur.Spec.Addresses = []string{"192.0.2.0/24"}
if err := c.Update(context.Background(), cur); err != nil {
t.Fatal(err)
}
_ = reconcileOnce(r, exp)
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); !apierrors.IsNotFound(err) {
t.Errorf("metallb pool should be GC'd after switch, got err=%v", err)
}
if _, err := getUnstructured(t, c, "cilium/Pool", "cozystack-default", ""); err != nil {
t.Errorf("cilium pool should exist after switch: %v", err)
}
}
func TestReconcile_RefusesTakeoverOfUnmanagedCR(t *testing.T) {
s := newScheme(t)
exp := exposure("")
// Pre-existing, operator-owned IPAddressPool with the same name.
foreign := &unstructured.Unstructured{}
foreign.SetGroupVersionKind(splitGVK("metallb/IPAddressPool"))
foreign.SetNamespace("cozy-metallb")
foreign.SetName("cozystack-default")
foreign.Object["spec"] = map[string]interface{}{"addresses": []interface{}{"10.0.0.0/24"}}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService(), defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24"), foreign).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
if err := reconcileOnce(r, exp); err == nil {
t.Fatal("expected reconcile error on take-over refusal")
}
got, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb")
if err != nil {
t.Fatal(err)
}
addrs, _, _ := unstructured.NestedStringSlice(got.Object, "spec", "addresses")
if len(addrs) != 1 || addrs[0] != "10.0.0.0/24" {
t.Errorf("foreign pool must be left untouched, got addresses %v", addrs)
}
cond := readyCondition(getExposure(t, c, "tenant-root", "root-ingress"))
if cond == nil || cond.Reason != "ResourceConflict" {
t.Fatalf("want Ready=False/ResourceConflict, got %+v", cond)
}
}
func TestReconcile_SecondExposureSharesOnePool(t *testing.T) {
// Two ServiceExposures in different namespaces resolving to one
// metallb class must converge on a SINGLE pool (no overlapping pools),
// scoped to BOTH namespaces. This is the multi-consumer case the
// class-level model exists to make correct.
s := newScheme(t)
class := defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")
expA := exposure("")
svcA := ingressService()
expB := &networkv1alpha1.ServiceExposure{
ObjectMeta: metav1.ObjectMeta{Name: "other-ingress", Namespace: "tenant-other"},
Spec: networkv1alpha1.ServiceExposureSpec{ServiceRef: corev1.LocalObjectReference{Name: "other-ingress"}},
}
svcB := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "other-ingress", Namespace: "tenant-other"},
Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer},
}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(class, expA, svcA, expB, svcB).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, expA)
_ = reconcileOnce(r, expB)
pools := &unstructured.UnstructuredList{}
pools.SetGroupVersionKind(schema.GroupVersionKind{Group: "metallb.io", Version: "v1beta1", Kind: "IPAddressPoolList"})
if err := c.List(context.Background(), pools); err != nil {
t.Fatal(err)
}
if len(pools.Items) != 1 {
t.Fatalf("want exactly 1 shared pool, got %d", len(pools.Items))
}
nsAlloc, _, _ := unstructured.NestedStringSlice(pools.Items[0].Object, "spec", "serviceAllocation", "namespaces")
if len(nsAlloc) != 2 || nsAlloc[0] != "tenant-other" || nsAlloc[1] != "tenant-root" {
t.Errorf("shared pool must be scoped to both namespaces (sorted), got %v", nsAlloc)
}
}
func TestReconcile_PreservesServerDefaultsAndIsIdempotent(t *testing.T) {
// The apiserver adds CRD-defaulted keys the backend never sets
// (metallb IPAddressPool autoAssign/avoidBuggyIPs). A whole-spec
// compare would rewrite the pool every reconcile and strip them; the
// subset merge must leave them intact and issue no Update.
s := newScheme(t)
exp := exposure("")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService(), defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
// Simulate the apiserver writing a defaulted key onto the pool.
pool, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb")
if err != nil {
t.Fatal(err)
}
_ = unstructured.SetNestedField(pool.Object, true, "spec", "autoAssign")
if err := c.Update(context.Background(), pool); err != nil {
t.Fatal(err)
}
pool, _ = getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb")
rvBefore := pool.GetResourceVersion()
// Reconcile again: managed keys are unchanged, so no Update must fire
// and the server default must survive.
_ = reconcileOnce(r, exp)
pool, err = getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb")
if err != nil {
t.Fatal(err)
}
if pool.GetResourceVersion() != rvBefore {
t.Errorf("pool was rewritten on a no-op reconcile (rv %s → %s)", rvBefore, pool.GetResourceVersion())
}
if auto, _, _ := unstructured.NestedBool(pool.Object, "spec", "autoAssign"); !auto {
t.Error("subset merge stripped the server-defaulted spec.autoAssign")
}
}
func TestReconcile_ClearsRemovedManagedKeyButKeepsServerDefaults(t *testing.T) {
// Clearing class.spec.interfaces must remove interfaces from the
// L2Advertisement (a managed key absent from the fresh render), while
// the apiserver-defaulted pool key (autoAssign) must survive.
s := newScheme(t)
class := &networkv1alpha1.ExposureClass{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Annotations: map[string]string{networkv1alpha1.IsDefaultExposureClassAnnotation: "true"},
},
Spec: networkv1alpha1.ExposureClassSpec{
Backend: networkv1alpha1.BackendMetalLB,
Addresses: []string{"192.0.2.0/24"},
Interfaces: []string{"eth0"},
},
}
exp := exposure("")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(class, exp, ingressService()).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
l2, err := getUnstructured(t, c, "metallb/L2Advertisement", "cozystack-default", "cozy-metallb")
if err != nil {
t.Fatal(err)
}
if ifaces, _, _ := unstructured.NestedStringSlice(l2.Object, "spec", "interfaces"); len(ifaces) != 1 {
t.Fatalf("precondition: L2Advertisement should have interfaces, got %v", ifaces)
}
// Server defaults a key on the pool.
pool, _ := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb")
_ = unstructured.SetNestedField(pool.Object, true, "spec", "autoAssign")
if err := c.Update(context.Background(), pool); err != nil {
t.Fatal(err)
}
// Operator clears interfaces.
cur := &networkv1alpha1.ExposureClass{}
if err := c.Get(context.Background(), types.NamespacedName{Name: "default"}, cur); err != nil {
t.Fatal(err)
}
cur.Spec.Interfaces = nil
if err := c.Update(context.Background(), cur); err != nil {
t.Fatal(err)
}
_ = reconcileOnce(r, exp)
l2, err = getUnstructured(t, c, "metallb/L2Advertisement", "cozystack-default", "cozy-metallb")
if err != nil {
t.Fatal(err)
}
if _, found, _ := unstructured.NestedSlice(l2.Object, "spec", "interfaces"); found {
t.Error("cleared class.spec.interfaces did not propagate; L2Advertisement still has interfaces")
}
pool, err = getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb")
if err != nil {
t.Fatal(err)
}
if auto, _, _ := unstructured.NestedBool(pool.Object, "spec", "autoAssign"); !auto {
t.Error("server-defaulted spec.autoAssign was stripped while removing a managed key")
}
}
func TestClassReconciler_DeletionGCsBackendCRs(t *testing.T) {
s := newScheme(t)
class := defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")
exp := exposure("")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(class, exp, ingressService()).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
cr := &ClassReconciler{Client: c, Scheme: s}
classKey := types.NamespacedName{Name: "default"}
// Class controller stamps its finalizer; exposure controller renders.
if _, err := cr.Reconcile(context.Background(), ctrl.Request{NamespacedName: classKey}); err != nil {
t.Fatal(err)
}
_ = reconcileOnce(r, exp)
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); err != nil {
t.Fatalf("pool should exist: %v", err)
}
// Delete the ExposureClass — its finalizer keeps it until the class
// controller reclaims the orphan-prone pool.
cur := &networkv1alpha1.ExposureClass{}
if err := c.Get(context.Background(), classKey, cur); err != nil {
t.Fatal(err)
}
if err := c.Delete(context.Background(), cur); err != nil {
t.Fatal(err)
}
if _, err := cr.Reconcile(context.Background(), ctrl.Request{NamespacedName: classKey}); err != nil {
t.Fatal(err)
}
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); !apierrors.IsNotFound(err) {
t.Errorf("pool must be GC'd on class deletion, got err=%v", err)
}
}
func TestReconcile_DeletionKeepsFinalizerOnTransientClassError(t *testing.T) {
// A transient (non-NotFound) error resolving the class during deletion
// must NOT drop the finalizer — otherwise the exposure vanishes without
// recomputing the shared pool, leaking it.
s := newScheme(t)
exp := exposure("gold")
exp.Finalizers = []string{exposureFinalizer}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService()).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*networkv1alpha1.ExposureClass); ok {
return apierrors.NewServiceUnavailable("apiserver hiccup")
}
return cl.Get(ctx, key, obj, opts...)
},
}).
Build()
r := &Reconciler{Client: c, Scheme: s}
// Put the exposure into deletion.
cur := getExposure(t, c, "tenant-root", "root-ingress")
if err := c.Delete(context.Background(), cur); err != nil {
t.Fatal(err)
}
if err := reconcileOnce(r, exp); err == nil {
t.Fatal("expected requeue error on transient class-resolve failure during deletion")
}
// The exposure must still exist with its finalizer intact.
got := &networkv1alpha1.ServiceExposure{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}, got); err != nil {
t.Fatalf("exposure must still exist (finalizer retained): %v", err)
}
if !controllerutil.ContainsFinalizer(got, exposureFinalizer) {
t.Error("finalizer was dropped on a transient error")
}
}
func TestReconcile_DeletionUnwedgesMisconfiguredClass(t *testing.T) {
// A pool backend with no addresses makes Desired error. Deleting the
// last exposure of such a class must still drop the finalizer (empty
// namespaces ⇒ nothing to render), not hang in Terminating forever.
for _, be := range []networkv1alpha1.ExposureBackend{networkv1alpha1.BackendMetalLB, networkv1alpha1.BackendCilium} {
s := newScheme(t)
class := defaultClass(be) // no addresses → Desired errors when namespaces non-empty
exp := exposure("")
exp.Finalizers = []string{exposureFinalizer}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(class, exp, ingressService()).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
cur := getExposure(t, c, "tenant-root", "root-ingress")
if err := c.Delete(context.Background(), cur); err != nil {
t.Fatal(err)
}
if err := reconcileOnce(r, exp); err != nil {
t.Fatalf("backend %s: deletion must not wedge, got %v", be, err)
}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}, &networkv1alpha1.ServiceExposure{}); !apierrors.IsNotFound(err) {
t.Errorf("backend %s: exposure should be gone after delete, got err=%v", be, err)
}
}
}
func TestReconcile_DeletionUnwedgesWhenOtherExposuresKeepMisconfiguredClass(t *testing.T) {
// Deleting a non-last exposure of a misconfigured (address-less) class:
// other exposures keep the namespace set non-empty, so Desired still
// errors. The deleting exposure must NOT wedge — no pool could have
// been created in this state, so removing it is safe.
s := newScheme(t)
class := defaultClass(networkv1alpha1.BackendMetalLB) // no addresses
expA := exposure("")
expA.Finalizers = []string{exposureFinalizer}
expB := &networkv1alpha1.ServiceExposure{
ObjectMeta: metav1.ObjectMeta{Name: "other-ingress", Namespace: "tenant-other", Finalizers: []string{exposureFinalizer}},
Spec: networkv1alpha1.ServiceExposureSpec{ServiceRef: corev1.LocalObjectReference{Name: "other-ingress"}},
}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(class, expA, ingressService(), expB).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
cur := getExposure(t, c, "tenant-root", "root-ingress")
if err := c.Delete(context.Background(), cur); err != nil {
t.Fatal(err)
}
if err := reconcileOnce(r, expA); err != nil {
t.Fatalf("deletion must not wedge on a misconfigured class with peers, got %v", err)
}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}, &networkv1alpha1.ServiceExposure{}); !apierrors.IsNotFound(err) {
t.Errorf("deleted exposure should be gone, got err=%v", err)
}
// The surviving exposure is untouched.
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-other", Name: "other-ingress"}, &networkv1alpha1.ServiceExposure{}); err != nil {
t.Errorf("surviving exposure must remain, got %v", err)
}
}
func TestReconcile_DeletionReclaimsStalePoolWhenClassMisconfigured(t *testing.T) {
// A class that WAS valid (pool created, scoped to both namespaces) then
// had its addresses cleared while exposures are live. Deleting one
// exposure must not leave the pool frozen with the deleted namespace
// still in its scope (isolation leak) — the controller reclaims the
// now-invalid pool entirely.
s := newScheme(t)
class := defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")
expA := exposure("")
expA.Finalizers = []string{exposureFinalizer}
expB := &networkv1alpha1.ServiceExposure{
ObjectMeta: metav1.ObjectMeta{Name: "other-ingress", Namespace: "tenant-other", Finalizers: []string{exposureFinalizer}},
Spec: networkv1alpha1.ServiceExposureSpec{ServiceRef: corev1.LocalObjectReference{Name: "other-ingress"}},
}
svcB := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "other-ingress", Namespace: "tenant-other"},
Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer},
}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(class, expA, ingressService(), expB, svcB).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, expA)
_ = reconcileOnce(r, expB)
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); err != nil {
t.Fatalf("precondition: pool should exist while class is valid: %v", err)
}
// Operator clears addresses → class can no longer render.
cur := &networkv1alpha1.ExposureClass{}
if err := c.Get(context.Background(), types.NamespacedName{Name: "default"}, cur); err != nil {
t.Fatal(err)
}
cur.Spec.Addresses = nil
if err := c.Update(context.Background(), cur); err != nil {
t.Fatal(err)
}
// Delete the tenant-root exposure.
del := getExposure(t, c, "tenant-root", "root-ingress")
if err := c.Delete(context.Background(), del); err != nil {
t.Fatal(err)
}
if err := reconcileOnce(r, expA); err != nil {
t.Fatalf("deletion must not wedge, got %v", err)
}
// The stale pool must be gone (no leftover scope on tenant-root), and
// the exposure removed.
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); !apierrors.IsNotFound(err) {
t.Errorf("stale pool must be reclaimed, got err=%v", err)
}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}, &networkv1alpha1.ServiceExposure{}); !apierrors.IsNotFound(err) {
t.Errorf("deleted exposure should be gone, got err=%v", err)
}
}
func TestReconcile_DeletionWithMissingClassDropsFinalizer(t *testing.T) {
// Deleting an exposure whose class no longer exists must not hang: the
// class is genuinely gone (ClassNotFound), nothing to recompute.
s := newScheme(t)
exp := exposure("ghost") // no such class
exp.Finalizers = []string{exposureFinalizer}
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService()).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
cur := getExposure(t, c, "tenant-root", "root-ingress")
if err := c.Delete(context.Background(), cur); err != nil {
t.Fatal(err)
}
if err := reconcileOnce(r, exp); err != nil {
t.Fatalf("deletion with a missing class must not error, got %v", err)
}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}, &networkv1alpha1.ServiceExposure{}); !apierrors.IsNotFound(err) {
t.Errorf("exposure should be gone after delete, got err=%v", err)
}
}
func TestReconcile_BackendCRDMissing(t *testing.T) {
// Simulate a cluster where the Cilium CRDs are not installed: the
// RESTMapper cannot map the GVK, so a Get/Create against it returns a
// NoKindMatchError. The controller must surface BackendUnavailable
// rather than a generic failure. The fake client tolerates unknown
// GVKs, so inject the mapper miss via an interceptor.
s := newScheme(t)
noCilium := func(gvk schema.GroupVersionKind) error {
if gvk.Group == "cilium.io" {
return &apimeta.NoKindMatchError{GroupKind: gvk.GroupKind(), SearchedVersions: []string{gvk.Version}}
}
return nil
}
exp := exposure("")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService(), defaultClass(networkv1alpha1.BackendCilium, "192.0.2.0/24")).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if err := noCilium(obj.GetObjectKind().GroupVersionKind()); err != nil {
return err
}
return cl.Get(ctx, key, obj, opts...)
},
}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
cond := readyCondition(getExposure(t, c, "tenant-root", "root-ingress"))
if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != "BackendUnavailable" {
t.Fatalf("want Ready=False/BackendUnavailable when the Cilium CRD is absent, got %+v", cond)
}
}
func TestReconcile_DeletionCleansUpOwnedCRs(t *testing.T) {
s := newScheme(t)
exp := exposure("")
c := fake.NewClientBuilder().WithScheme(s).
WithObjects(exp, ingressService(), defaultClass(networkv1alpha1.BackendMetalLB, "192.0.2.0/24")).
WithStatusSubresource(&networkv1alpha1.ServiceExposure{}).
Build()
r := &Reconciler{Client: c, Scheme: s}
_ = reconcileOnce(r, exp)
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); err != nil {
t.Fatalf("pool should exist before delete: %v", err)
}
cur := getExposure(t, c, "tenant-root", "root-ingress")
if err := c.Delete(context.Background(), cur); err != nil {
t.Fatal(err)
}
if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}}); err != nil {
t.Fatalf("deletion reconcile: %v", err)
}
if _, err := getUnstructured(t, c, "metallb/IPAddressPool", "cozystack-default", "cozy-metallb"); !apierrors.IsNotFound(err) {
t.Errorf("pool should be cleaned up on delete, got err=%v", err)
}
// Finalizer removed ⇒ exposure gone.
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-root", Name: "root-ingress"}, &networkv1alpha1.ServiceExposure{}); !apierrors.IsNotFound(err) {
t.Errorf("exposure should be gone after finalizer removal, got err=%v", err)
}
}
@@ -1,146 +0,0 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package serviceexposure
import (
"context"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
networkv1alpha1 "github.com/cozystack/cozystack/api/network/v1alpha1"
)
// reconcileError carries a machine-readable Reason for the Ready=False
// condition alongside the underlying error, so operators reading
// `kubectl get svcexp` see e.g. ClassNotFound rather than a generic
// failure.
type reconcileError struct {
reason string
err error
}
func (e *reconcileError) Error() string { return e.err.Error() }
func (e *reconcileError) Unwrap() error { return e.err }
// failf builds a reconcileError with the given reason.
func failf(reason string, err error) error {
return &reconcileError{reason: reason, err: err}
}
// reasonOf extracts the Reason from a reconcileError, defaulting to
// "ReconcileError" for plain errors.
func reasonOf(err error) string {
if re, ok := err.(*reconcileError); ok {
return re.reason
}
return "ReconcileError"
}
// markReady writes a successful status: resolved backend, assigned IPs and
// a Ready condition reflecting whether the backend has actually allocated.
func (r *Reconciler) markReady(
ctx context.Context,
exp *networkv1alpha1.ServiceExposure,
backend string,
assignedIPs []string,
ready bool,
notReadyReason string,
) error {
cond := metav1.Condition{
Type: "Ready",
ObservedGeneration: exp.Generation,
}
if ready {
cond.Status = metav1.ConditionTrue
cond.Reason = "Reconciled"
cond.Message = "Exposure programmed by backend " + backend
} else {
cond.Status = metav1.ConditionFalse
cond.Reason = notReadyReason
cond.Message = "Backend " + backend + " has not assigned an address yet"
}
stale := exp.DeepCopy()
stale.Status.ObservedGeneration = exp.Generation
stale.Status.ResolvedBackend = backend
stale.Status.AssignedIPs = assignedIPs
apimeta.SetStatusCondition(&stale.Status.Conditions, cond)
if statusEqual(exp.Status, stale.Status) {
return nil
}
exp.Status = stale.Status
return r.Status().Update(ctx, exp)
}
// markFailed records a Ready=False condition with the cause's reason.
func (r *Reconciler) markFailed(ctx context.Context, exp *networkv1alpha1.ServiceExposure, cause error) error {
cond := metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
ObservedGeneration: exp.Generation,
Reason: reasonOf(cause),
Message: cause.Error(),
}
stale := exp.DeepCopy()
stale.Status.ObservedGeneration = exp.Generation
apimeta.SetStatusCondition(&stale.Status.Conditions, cond)
if statusEqual(exp.Status, stale.Status) {
return nil
}
exp.Status = stale.Status
return r.Status().Update(ctx, exp)
}
func statusEqual(a, b networkv1alpha1.ServiceExposureStatus) bool {
if a.ObservedGeneration != b.ObservedGeneration {
return false
}
if a.ResolvedBackend != b.ResolvedBackend {
return false
}
if !stringSliceEqual(a.AssignedIPs, b.AssignedIPs) {
return false
}
if len(a.Conditions) != len(b.Conditions) {
return false
}
for i := range a.Conditions {
ac, bc := a.Conditions[i], b.Conditions[i]
if ac.Type != bc.Type ||
ac.Status != bc.Status ||
ac.Reason != bc.Reason ||
ac.Message != bc.Message ||
ac.ObservedGeneration != bc.ObservedGeneration {
return false
}
}
return true
}
func stringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+3 -4
View File
@@ -116,10 +116,9 @@ stringData:
where the admin route attaches. */}}
expose-ingress-admin: {{ .Values.publishing.ingressNameAdmin | default "" | quote }}
expose-external-ips: {{ .Values.publishing.externalIPs | join "," | quote }}
{{- /* Name of the ExposureClass the host ingress publishes through.
Empty keeps the externalIPs path; set it to render a
ServiceExposure for the ingress Service (see extra/ingress). */}}
exposure-class: {{ .Values.publishing.exposureClass.name | default "" | quote }}
{{- /* Native loadBalancerClass for the host ingress LoadBalancer Service
(see extra/ingress). Empty lets the default LB controller claim it. */}}
load-balancer-class: {{ .Values.publishing.loadBalancerClass | default "" | quote }}
{{- /* Host PROXY-protocol switch. extra/ingress reads this and applies
use-proxy-protocol to the external-facing ingress (the namespace
named by expose-ingress), keeping it host-scoped like expose-external-ips. */}}
@@ -1,36 +0,0 @@
{{- /*
Render the cluster-scoped ExposureClass that binds the operator's chosen
LoadBalancer backend to a logical class name. The cozystack-controller
resolves ServiceExposures to this class and provisions the backend's VIP
pool and announcer. Only rendered when an explicit name is set, so the
default externalIPs path stays untouched.
*/}}
{{- with .Values.publishing.exposureClass }}
{{- if .name }}
{{- /*
Guard: a pool/cloud backend (metallb, cilium, robotlb) needs the host
ingress Service to be type LoadBalancer, but publishing.externalIPs
forces it to ClusterIP + spec.externalIPs (extra/ingress). The two
together leave the pool allocated but never consumed and the exposure
stuck Ready=False forever — fail loudly instead of shipping the dead end.
*/}}
{{- if and (ne (.backend | default "externalIPs") "externalIPs") $.Values.publishing.externalIPs }}
{{- fail "publishing.exposureClass with a pool/cloud backend (metallb/cilium/robotlb) is mutually exclusive with publishing.externalIPs: the host ingress Service renders as ClusterIP+externalIPs and never becomes type LoadBalancer, so the backend pool is allocated but never used. Clear publishing.externalIPs to migrate onto the LoadBalancer path, or set exposureClass.backend=externalIPs." }}
{{- end }}
apiVersion: network.cozystack.io/v1alpha1
kind: ExposureClass
metadata:
name: {{ .name | quote }}
{{- if .isDefault }}
annotations:
exposureclass.network.cozystack.io/is-default-class: "true"
{{- end }}
spec:
backend: {{ .backend | default "externalIPs" }}
l2: {{ dig "l2" true . }}
{{- with .addresses }}
addresses:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
@@ -1,26 +0,0 @@
suite: apps.yaml exposure-class platform → tenant value wiring
templates:
- templates/apps.yaml
release:
name: cozy-platform
namespace: cozy-system
# The host ingress (extra/ingress) reads _cluster.exposure-class to decide
# whether to render a ServiceExposure. The default must be an empty string
# so the externalIPs path is unchanged.
tests:
- it: writes an empty exposure-class by default
asserts:
- matchRegex:
path: stringData["values.yaml"]
pattern: 'exposure-class:\s*""'
- it: writes the configured class name into cozystack-values
set:
publishing.exposureClass.name: prod-lb
asserts:
- matchRegex:
path: stringData["values.yaml"]
pattern: 'exposure-class:\s*"prod-lb"'
@@ -1,99 +0,0 @@
suite: ExposureClass rendering
templates:
- templates/exposureclass.yaml
release:
name: cozy-platform
namespace: cozy-system
# Pins the operator-facing knob that renders a cluster-scoped ExposureClass
# from platform values. The default (empty name) must render nothing so the
# externalIPs path stays untouched on upgrade.
tests:
- it: renders nothing by default (empty name keeps the externalIPs path)
asserts:
- hasDocuments:
count: 0
- it: renders a metallb ExposureClass with addresses and default annotation
set:
publishing.exposureClass.name: prod-lb
publishing.exposureClass.backend: metallb
publishing.exposureClass.addresses:
- 192.0.2.0/24
publishing.exposureClass.l2: true
publishing.exposureClass.isDefault: true
asserts:
- hasDocuments:
count: 1
- equal:
path: kind
value: ExposureClass
- equal:
path: metadata.name
value: prod-lb
- equal:
path: metadata.annotations["exposureclass.network.cozystack.io/is-default-class"]
value: "true"
- equal:
path: spec.backend
value: metallb
- equal:
path: spec.addresses[0]
value: 192.0.2.0/24
- it: omits the default annotation when isDefault is false
set:
publishing.exposureClass.name: prod-lb
publishing.exposureClass.isDefault: false
asserts:
- notExists:
path: metadata.annotations["exposureclass.network.cozystack.io/is-default-class"]
- it: honours backend and l2 overrides
set:
publishing.exposureClass.name: cilium-lb
publishing.exposureClass.backend: cilium
publishing.exposureClass.l2: false
publishing.exposureClass.addresses:
- 198.51.100.10-198.51.100.20
asserts:
- equal:
path: spec.backend
value: cilium
- equal:
path: spec.l2
value: false
- it: defaults l2 to true when omitted from a trimmed values file
set:
publishing.exposureClass.name: prod-lb
publishing.exposureClass.l2: null
asserts:
- equal:
path: spec.l2
value: true
- it: fails when a pool backend is combined with publishing.externalIPs
set:
publishing.externalIPs:
- 203.0.113.10
publishing.exposureClass.name: prod-lb
publishing.exposureClass.backend: metallb
asserts:
- failedTemplate:
errorPattern: mutually exclusive with publishing.externalIPs
- it: allows externalIPs backend alongside publishing.externalIPs
set:
publishing.externalIPs:
- 203.0.113.10
publishing.exposureClass.name: pin
publishing.exposureClass.backend: externalIPs
asserts:
- hasDocuments:
count: 1
- equal:
path: spec.backend
value: externalIPs
+7 -17
View File
@@ -71,23 +71,13 @@ publishing:
- cdi-uploadproxy
apiServerEndpoint: "" # example: "https://api.example.org"
externalIPs: []
# Mechanism-agnostic LoadBalancer exposure — the foundation for migrating
# the host ingress off the deprecated Service.spec.externalIPs (KEP-5707).
# When name is set, the platform renders a cluster-scoped ExposureClass
# and the host ingress (with externalIPs empty) publishes its Service as
# type: LoadBalancer; the cozystack-controller translates the class into
# the chosen backend's VIP pool and announcer:
# externalIPs - node-IP pin, no pool/announcer (the historical default)
# metallb - metallb.io IPAddressPool + L2Advertisement
# cilium - CiliumLoadBalancerIPPool + CiliumL2AnnouncementPolicy
# robotlb - cloud (Hetzner) LB; allocation/announcement off-cluster
# Leave name empty (default) to keep the externalIPs path unchanged.
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
# Native loadBalancerClass for the host ingress. When externalIPs is empty the
# host ingress publishes its Service as type: LoadBalancer; set this to the
# loadBalancerClass of the LoadBalancer controller that should handle it. The
# admin provisions the matching address pool / announcer out of band (a MetalLB
# IPAddressPool, a Cilium LB-IPAM pool, a cloud LB, etc.). Empty lets the
# cluster's default LoadBalancer controller claim the Service.
loadBalancerClass: ""
# Enable PROXY-protocol on the host ingress-nginx and auto-deploy
# ouroboros (system/ouroboros) to fix the resulting hairpin-NAT
# problem (intra-cluster traffic to the cluster's own public hostnames
@@ -1,5 +1,6 @@
{{- $exposeIngress := (index .Values._cluster "expose-ingress") | default "tenant-root" }}
{{- $exposeExternalIPs := (index .Values._cluster "expose-external-ips") | default "" | nospace }}
{{- $loadBalancerClass := (index .Values._cluster "load-balancer-class") | default "" }}
{{- $wildcardSecret := (index .Values._cluster "wildcard-secret-name") | default "" }}
{{- /* PROXY-protocol is enabled either explicitly per-app (.Values.proxyProtocol)
or, for the host control plane, via the platform-level publishing.proxyProtocol
@@ -81,6 +82,9 @@ spec:
{{- else }}
type: LoadBalancer
externalTrafficPolicy: Local
{{- with $loadBalancerClass }}
loadBalancerClass: {{ . }}
{{- end }}
{{- end }}
{{- if or .Values.whitelist .Values.cloudflareProxy $proxyProtocol }}
config:
@@ -1,21 +0,0 @@
{{- /*
Publish the host ingress-nginx Service through the platform's configured
LoadBalancer backend by declaring a ServiceExposure. Only the host
(external-facing) ingress — the namespace named by expose-ingress —
renders it, and only when an exposure class is configured. The
cozystack-controller owns the backend pool/announcer downstream; the
ingress-nginx Service itself is already rendered as type LoadBalancer by
nginx-ingress.yaml whenever expose-external-ips is empty.
*/}}
{{- $exposeIngress := (index .Values._cluster "expose-ingress") | default "tenant-root" }}
{{- $exposureClass := (index .Values._cluster "exposure-class") | default "" }}
{{- if and $exposureClass (eq $exposeIngress .Release.Namespace) }}
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
metadata:
name: {{ trimPrefix "tenant-" .Release.Namespace }}-ingress
spec:
serviceRef:
name: {{ trimPrefix "tenant-" .Release.Namespace }}-ingress-controller
exposureClassName: {{ $exposureClass | quote }}
{{- end }}
@@ -1,48 +0,0 @@
suite: ingress ServiceExposure wiring
templates:
- templates/serviceexposure.yaml
release:
name: ingress
tests:
- it: renders no ServiceExposure by default (externalIPs path)
set:
_cluster: {}
release:
namespace: tenant-root
asserts:
- hasDocuments:
count: 0
- it: renders a ServiceExposure for the host ingress when a class is set
set:
_cluster:
expose-ingress: tenant-root
exposure-class: prod-lb
release:
namespace: tenant-root
asserts:
- hasDocuments:
count: 1
- equal:
path: kind
value: ServiceExposure
- equal:
path: metadata.name
value: root-ingress
- equal:
path: spec.serviceRef.name
value: root-ingress-controller
- equal:
path: spec.exposureClassName
value: prod-lb
- it: leaves a non-host ingress without a ServiceExposure
set:
_cluster:
expose-ingress: tenant-root
exposure-class: prod-lb
release:
namespace: tenant-foo
asserts:
- hasDocuments:
count: 0
@@ -1,171 +0,0 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.16.4
name: exposureclasses.network.cozystack.io
spec:
group: network.cozystack.io
names:
kind: ExposureClass
listKind: ExposureClassList
plural: exposureclasses
shortNames:
- expcls
singular: exposureclass
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .spec.backend
name: Backend
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
ExposureClass is the Schema for the exposureclasses API. It is the
cluster-admin-facing configuration that binds a logical class name to a
LoadBalancer backend and its address scope. Tenants reference it by name
from a ServiceExposure; the cozystack-controller renders the backend's
pool and announcer resources from this CR.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
ExposureClassSpec is the cluster-admin backend configuration. It maps a
logical exposure class to a concrete backend and its address scope,
following the StorageClass / IngressClass / GatewayClass pattern: the
admin owns the backend choice, tenants reference the class by name.
properties:
addresses:
description: |-
Addresses is the VIP address scope for pool-based backends (metallb,
cilium), as CIDRs or start-end ranges. These are virtual VIPs, NOT
node public IPs. Ignored by the externalIPs and robotlb backends.
items:
type: string
type: array
backend:
description: Backend selects the concrete allocation+announcement
mechanism.
enum:
- externalIPs
- metallb
- cilium
- robotlb
type: string
interfaces:
description: |-
Interfaces optionally scopes L2 announcement to the named host
network interfaces (cilium CiliumL2AnnouncementPolicy.spec.interfaces;
metallb L2Advertisement.spec.interfaces). Empty announces on all.
items:
type: string
type: array
l2:
default: true
description: |-
L2 enables L2 (ARP/NDP) announcement for bare-metal backends
(metallb, cilium). Defaults to true. Set false when announcement is
handled out of band (e.g. BGP added later, or a routed network).
type: boolean
required:
- backend
type: object
status:
description: ExposureClassStatus reports the observed state of the ExposureClass.
properties:
conditions:
description: |-
Conditions describes the current state of the ExposureClass.
Standard condition type: Ready.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
description: |-
ObservedGeneration mirrors the .metadata.generation reflected in the
latest reconciled state.
format: int64
type: integer
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -1,187 +0,0 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.16.4
name: serviceexposures.network.cozystack.io
spec:
group: network.cozystack.io
names:
kind: ServiceExposure
listKind: ServiceExposureList
plural: serviceexposures
shortNames:
- svcexp
singular: serviceexposure
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.serviceRef.name
name: Service
type: string
- jsonPath: .spec.exposureClassName
name: Class
type: string
- jsonPath: .status.resolvedBackend
name: Backend
type: string
- jsonPath: .status.assignedIPs
name: IPs
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
ServiceExposure is the Schema for the serviceexposures API. It expresses
the mechanism-agnostic tenant intent to expose a Service externally; the
cozystack-controller reconciles the backend-specific pool and announcer
resources from this CR and reports the assigned IPs in status.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
ServiceExposureSpec is the mechanism-agnostic tenant intent: "expose
this Service externally via the named ExposureClass". It never names a
backend; the controller translates it into whichever backend the cluster
admin wired behind the referenced class.
properties:
exposureClassName:
description: |-
ExposureClassName references a cluster-scoped ExposureClass the
admin configured. Empty selects the cluster default ExposureClass
(the one annotated exposureclass.network.cozystack.io/is-default-class=true).
type: string
serviceRef:
description: |-
ServiceRef names the Service in this namespace to expose. The
controller scopes the backend pool/announcer to that Service and
reads its assigned IP back into status. The Service's own type is
owned by the chart that renders it, not by this controller.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
required:
- serviceRef
type: object
status:
description: ServiceExposureStatus reports the observed state of the exposure.
properties:
assignedIPs:
description: |-
AssignedIPs reports the external addresses the backend assigned,
read back from the Service (status.loadBalancer.ingress or the
externalIPs pin).
items:
type: string
type: array
conditions:
description: |-
Conditions describes the current state of the ServiceExposure.
Standard condition type: Ready.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
description: |-
ObservedGeneration mirrors the .metadata.generation reflected in the
latest reconciled state.
format: int64
type: integer
resolvedBackend:
description: |-
ResolvedBackend records which backend the referenced class resolved
to, so operators can see where an exposure was routed.
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -3,19 +3,9 @@ apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: cozystack-controller
rules:
- apiGroups: ['cozystack.io', 'gateway.cozystack.io', 'network.cozystack.io']
- apiGroups: ['cozystack.io', 'gateway.cozystack.io']
resources: ['*']
verbs: ['*']
# ServiceExposureReconciler renders the backend LoadBalancer pool /
# announcer CRs (MetalLB IPAddressPool + L2Advertisement, Cilium
# CiliumLoadBalancerIPPool + CiliumL2AnnouncementPolicy). The catch-all
# read rule below covers get/list/watch; these add the write verbs.
- apiGroups: ["metallb.io"]
resources: ["ipaddresspools", "l2advertisements"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["cilium.io"]
resources: ["ciliumloadbalancerippools", "ciliuml2announcementpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# TenantGatewayReconciler renders Gateway / HTTPRoute / TLSRoute and
# reads back their status to populate TenantGateway.Status.
- apiGroups: ["gateway.networking.k8s.io"]