mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-10 06:24:09 +09:00
Add the serviceexposure controller to the cozystack-controller binary. It resolves a ServiceExposure's ExposureClass to a backend and renders the backend-specific pool and announcer CRs, then reports the assigned IPs in status. Backends are pluggable behind a small interface, one per file: - externalIPs: renders nothing (the node owns the address) — preserves the historical default behaviour. - metallb: IPAddressPool + L2Advertisement in cozy-metallb, scoped to the target Service's namespace. - cilium: CiliumLoadBalancerIPPool (cilium.io/v2) + CiliumL2AnnouncementPolicy (cilium.io/v2alpha1), scoped to the Service via serviceSelector. - robotlb: renders nothing (the cloud LB owns allocation and announcement). The rendered CRs belong to foreign API groups not in the Go scheme, so they are emitted as unstructured objects. They carry no OwnerReference (the Cilium CRs are cluster-scoped and the MetalLB CRs live in a different namespace than the owning ServiceExposure), so ownership is tracked via a management label plus owner annotations, cleanup is driven by a finalizer, and the controller refuses to take over an identically-named resource it does not manage. The controller never mutates the target Service, leaving its shape to the chart that renders it. Grant the controller write access to metallb.io and cilium.io pool / announcer resources. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
101 lines
3.4 KiB
Go
101 lines
3.4 KiB
Go
/*
|
|
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)
|
|
}
|