272 lines
9.6 KiB
Go
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
|
|
}
|