mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-28 15:47:33 +09:00
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:
1 parent
63fcebd804
commit
3dbc7d3662
32 files changed
+16
-3795
No files matched your search
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
-171
@@ -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: {}
|
||||
-187
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user