cozystack/internal/cozyvaluesreplicator/cozyvaluesreplicator.go
Timofei Larkin 3a1e7fdd8f Improve Reconcile function
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-01-06 18:53:17 +03:00

272 lines
9.6 KiB
Go

/*
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.
*/
package cozyvaluesreplicator
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
// SecretReplicatorReconciler replicates a source secret to namespaces matching a label selector.
type SecretReplicatorReconciler struct {
client.Client
Scheme *runtime.Scheme
// Source of truth:
SourceNamespace string
SecretName string
// Namespaces to replicate into:
// (e.g. labels.SelectorFromSet(labels.Set{"tenant":"true"}), or metav1.LabelSelectorAsSelector(...))
TargetNamespaceSelector labels.Selector
}
func (r *SecretReplicatorReconciler) SetupWithManager(mgr ctrl.Manager) error {
// 1) Primary watch for requirement (b):
// Reconcile any Secret named r.SecretName in any namespace (includes source too).
// This keeps Secrets in cache and causes "copy changed -> reconcile it" to happen.
secretNameOnly := predicate.NewPredicateFuncs(func(obj client.Object) bool {
return obj.GetName() == r.SecretName
})
// 2) Secondary watch for requirement (c):
// When the *source* Secret changes, fan-out reconcile requests to every matching namespace.
onlySourceSecret := predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return isSourceSecret(e.Object, r) },
UpdateFunc: func(e event.UpdateEvent) bool { return isSourceSecret(e.ObjectNew, r) },
DeleteFunc: func(e event.DeleteEvent) bool { return isSourceSecret(e.Object, r) },
GenericFunc: func(e event.GenericEvent) bool {
return isSourceSecret(e.Object, r)
},
}
// Fan-out mapper for source Secret events -> one request per matching target namespace.
fanOutOnSourceSecret := handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request {
// List namespaces *from the cache* (because we also watch Namespaces below).
var nsList corev1.NamespaceList
if err := r.List(ctx, &nsList); err != nil {
// If list fails, best-effort: return nothing; reconcile will be retried by next event.
return nil
}
reqs := make([]reconcile.Request, 0, len(nsList.Items))
for i := range nsList.Items {
ns := &nsList.Items[i]
if ns.Name == r.SourceNamespace {
continue
}
if r.TargetNamespaceSelector != nil && !r.TargetNamespaceSelector.Matches(labels.Set(ns.Labels)) {
continue
}
reqs = append(reqs, reconcile.Request{
NamespacedName: types.NamespacedName{
Namespace: ns.Name,
Name: r.SecretName,
},
})
}
return reqs
})
// 3) Namespace watch for requirement (a):
// When a namespace is created/updated to match selector, enqueue reconcile for the Secret copy in that namespace.
enqueueOnNamespaceMatch := handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
ns, ok := obj.(*corev1.Namespace)
if !ok {
return nil
}
if ns.Name == r.SourceNamespace {
return nil
}
if r.TargetNamespaceSelector != nil && !r.TargetNamespaceSelector.Matches(labels.Set(ns.Labels)) {
return nil
}
return []reconcile.Request{{
NamespacedName: types.NamespacedName{
Namespace: ns.Name,
Name: r.SecretName,
},
}}
})
// Only trigger from namespace events where the label match may be (or become) true.
// (You can keep this simple; it's fine if it fires on any update—your Reconcile should be idempotent.)
namespaceMayMatter := predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
ns, ok := e.Object.(*corev1.Namespace)
return ok && (r.TargetNamespaceSelector == nil || r.TargetNamespaceSelector.Matches(labels.Set(ns.Labels)))
},
UpdateFunc: func(e event.UpdateEvent) bool {
oldNS, okOld := e.ObjectOld.(*corev1.Namespace)
newNS, okNew := e.ObjectNew.(*corev1.Namespace)
if !okOld || !okNew {
return false
}
// Fire if it matches now OR matched before (covers transitions both ways; reconcile can decide what to do).
oldMatch := r.TargetNamespaceSelector == nil || r.TargetNamespaceSelector.Matches(labels.Set(oldNS.Labels))
newMatch := r.TargetNamespaceSelector == nil || r.TargetNamespaceSelector.Matches(labels.Set(newNS.Labels))
return oldMatch || newMatch
},
DeleteFunc: func(event.DeleteEvent) bool { return false }, // nothing to do on namespace delete
GenericFunc: func(event.GenericEvent) bool { return false },
}
return ctrl.NewControllerManagedBy(mgr).
// (b) Watch all Secrets with the chosen name; this also ensures Secret objects are cached.
For(&corev1.Secret{}, builder.WithPredicates(secretNameOnly)).
// (c) Add a second watch on Secret, but only for the source secret, and fan-out to all namespaces.
Watches(
&corev1.Secret{},
fanOutOnSourceSecret,
builder.WithPredicates(onlySourceSecret),
).
// (a) Watch Namespaces so they're cached and so "namespace appears / starts matching" enqueues reconcile.
Watches(
&corev1.Namespace{},
enqueueOnNamespaceMatch,
builder.WithPredicates(namespaceMayMatter),
).
Complete(r)
}
func isSourceSecret(obj client.Object, r *SecretReplicatorReconciler) bool {
if obj == nil {
return false
}
return obj.GetNamespace() == r.SourceNamespace && obj.GetName() == r.SecretName
}
func (r *SecretReplicatorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Ignore requests that don't match our secret name or are for the source namespace
if req.Name != r.SecretName || req.Namespace == r.SourceNamespace {
return ctrl.Result{}, nil
}
// Verify the target namespace still exists and matches the selector
targetNamespace := &corev1.Namespace{}
if err := r.Get(ctx, types.NamespacedName{Name: req.Namespace}, targetNamespace); err != nil {
if apierrors.IsNotFound(err) {
// Namespace doesn't exist, nothing to do
return ctrl.Result{}, nil
}
logger.Error(err, "Failed to get target namespace", "namespace", req.Namespace)
return ctrl.Result{}, err
}
// Check if namespace still matches the selector
if r.TargetNamespaceSelector != nil && !r.TargetNamespaceSelector.Matches(labels.Set(targetNamespace.Labels)) {
// Namespace no longer matches selector, delete the replicated secret if it exists
replicatedSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: req.Namespace,
Name: req.Name,
},
}
if err := r.Delete(ctx, replicatedSecret); err != nil && !apierrors.IsNotFound(err) {
logger.Error(err, "Failed to delete replicated secret from non-matching namespace",
"namespace", req.Namespace, "secret", req.Name)
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// Get the source secret
originalSecret := &corev1.Secret{}
if err := r.Get(ctx, types.NamespacedName{Namespace: r.SourceNamespace, Name: r.SecretName}, originalSecret); err != nil {
if apierrors.IsNotFound(err) {
// Source secret doesn't exist, delete the replicated secret if it exists
replicatedSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: req.Namespace,
Name: req.Name,
},
}
if err := r.Delete(ctx, replicatedSecret); err != nil && !apierrors.IsNotFound(err) {
logger.Error(err, "Failed to delete replicated secret after source secret deletion",
"namespace", req.Namespace, "secret", req.Name)
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
logger.Error(err, "Failed to get source secret",
"namespace", r.SourceNamespace, "secret", r.SecretName)
return ctrl.Result{}, err
}
// Create or update the replicated secret
replicatedSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: req.Namespace,
Name: req.Name,
},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, replicatedSecret, func() error {
// Copy the secret data and type from the source
replicatedSecret.Data = make(map[string][]byte)
for k, v := range originalSecret.Data {
replicatedSecret.Data[k] = v
}
replicatedSecret.Type = originalSecret.Type
// Copy labels and annotations from source (if any)
if originalSecret.Labels != nil {
if replicatedSecret.Labels == nil {
replicatedSecret.Labels = make(map[string]string)
}
for k, v := range originalSecret.Labels {
replicatedSecret.Labels[k] = v
}
}
if originalSecret.Annotations != nil {
if replicatedSecret.Annotations == nil {
replicatedSecret.Annotations = make(map[string]string)
}
for k, v := range originalSecret.Annotations {
replicatedSecret.Annotations[k] = v
}
}
return nil
})
if err != nil {
logger.Error(err, "Failed to create or update replicated secret",
"namespace", req.Namespace, "secret", req.Name)
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}