diff --git a/feature/feature.go b/feature/feature.go new file mode 100644 index 000000000..dd1db259e --- /dev/null +++ b/feature/feature.go @@ -0,0 +1,20 @@ +package feature + +import ( + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/component-base/featuregate" +) + +const ( + // V2ProvKcfgPatch is used to enable patching of the v2prov created kubeconfig secrets so that they + // can be used with CAPI 1.5.x. + V2ProvKcfgPatch featuregate.Feature = "V2ProvKcfgPatch" +) + +func init() { + runtime.Must(MutableGates.Add(defaultGates)) +} + +var defaultGates = map[featuregate.Feature]featuregate.FeatureSpec{ + V2ProvKcfgPatch: {Default: false, PreRelease: featuregate.Beta}, +} diff --git a/feature/gates.go b/feature/gates.go new file mode 100644 index 000000000..bc083a2ce --- /dev/null +++ b/feature/gates.go @@ -0,0 +1,18 @@ +package feature + +import ( + "k8s.io/component-base/featuregate" + "sigs.k8s.io/cluster-api/feature" +) + +var ( + // MutableGates is a mutable version of DefaultFeatureGate. + // Only top-level commands/options setup and the k8s.io/component-base/featuregate/testing package should make use of this. + // Tests that need to modify featuregate gates for the duration of their test should use: + // defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features., )() + MutableGates featuregate.MutableFeatureGate = feature.MutableGates + + // Gates is a shared global FeatureGate. + // Top-level commands/options setup that needs to modify this featuregate gate should use DefaultMutableFeatureGate. + Gates featuregate.FeatureGate = MutableGates +) diff --git a/internal/controllers/patch_kcfg_controller.go b/internal/controllers/patch_kcfg_controller.go new file mode 100644 index 000000000..e4018f7fa --- /dev/null +++ b/internal/controllers/patch_kcfg_controller.go @@ -0,0 +1,173 @@ +/* +Copyright 2023 SUSE. + +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 controllers + +import ( + "context" + "fmt" + + provisioningv1 "github.com/rancher-sandbox/rancher-turtles/internal/rancher/provisioning/v1" + turtlespredicates "github.com/rancher-sandbox/rancher-turtles/util/predicates" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/retry" + capi "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/controllers/external" + "sigs.k8s.io/cluster-api/controllers/remote" + "sigs.k8s.io/cluster-api/util/predicates" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type V2ProvKcfgSecretReconciler struct { + Client client.Client + RancherClient client.Client + recorder record.EventRecorder + WatchFilterValue string + Scheme *runtime.Scheme + + controller controller.Controller + externalTracker external.ObjectTracker + remoteClientGetter remote.ClusterClientGetter +} + +func (r *V2ProvKcfgSecretReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, options controller.Options) error { + log := log.FromContext(ctx) + + if r.remoteClientGetter == nil { + r.remoteClientGetter = remote.NewClusterClient + } + + capiPredicates := predicates.All(log, + turtlespredicates.V2ProvClusterOwned(log), + turtlespredicates.NameHasSuffix(log, "-kubeconfig"), + ) + + c, err := ctrl.NewControllerManagedBy(mgr). + For(&corev1.Secret{}). + WithOptions(options). + WithEventFilter(capiPredicates). + Build(r) + if err != nil { + return fmt.Errorf("creating new controller: %w", err) + } + + r.recorder = mgr.GetEventRecorderFor("rancher-turtles-v2prov") + r.controller = c + r.externalTracker = external.ObjectTracker{ + Controller: c, + } + + return nil +} + +// +kubebuilder:rbac:groups="",resources=secrets;events,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;create;update +// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters;clusters/status,verbs=get;list;watch +// +kubebuilder:rbac:groups=provisioning.cattle.io,resources=clusters;clusters/status,verbs=get;list;watch + +// Reconcile will patch v2prov created kubeconfig secrets to add the required owner label if its missing. +func (r *V2ProvKcfgSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, reterr error) { + log := log.FromContext(ctx) + log.Info("Reconciling v2prov cluster") + + secret := &corev1.Secret{} + if err := r.Client.Get(ctx, req.NamespacedName, secret); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{Requeue: true}, nil + } + + return ctrl.Result{Requeue: true}, err + } + + _, ok := secret.Labels[capi.ClusterNameLabel] + if ok { + log.V(4).Info("kubeconfig secret %s/%s already has the capi cluster label", secret.Name, secret.Name) + + return ctrl.Result{}, nil + } + + clusterName, err := r.getClusterName(ctx, secret) + if err != nil { + return ctrl.Result{}, fmt.Errorf("getting cluster name from secret: %w", err) + } + + if clusterName == "" { + log.Info("Could not determine cluster name from kubeconfig secret") + + return ctrl.Result{}, nil + } + + if err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + secretCopy := secret.DeepCopy() + if secretCopy.Labels == nil { + secretCopy.Labels = map[string]string{} + } + secretCopy.Labels[capi.ClusterNameLabel] = clusterName + + patchBase := client.MergeFromWithOptions(secret.DeepCopy(), client.MergeFromWithOptimisticLock{}) + + if err := r.Client.Patch(ctx, secret, patchBase); err != nil { + return fmt.Errorf("failed to patch secret: %w", err) + } + + log.V(4).Info("patched kubeconfig secret", "name", secret.Name, "namespace", secret.Namespace, "cluster", clusterName) + + return nil + }); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *V2ProvKcfgSecretReconciler) getClusterName(ctx context.Context, secret *corev1.Secret) (string, error) { + v2ProvClusterName := "" + + for _, ref := range secret.OwnerReferences { + if ref.Kind == provisioningv1.GroupVersion.Identifier() { + if ref.Kind != "Cluster" { + v2ProvClusterName = ref.Name + + break + } + } + } + + if v2ProvClusterName == "" { + return "", nil + } + + v2ProvCluster := &provisioningv1.Cluster{} + + if err := r.Client.Get(ctx, types.NamespacedName{Name: v2ProvClusterName, Namespace: secret.Namespace}, v2ProvCluster); err != nil { + return "", fmt.Errorf("Getting v2prov cluster: %w", err) + } + + for _, ref := range v2ProvCluster.OwnerReferences { + if ref.Kind == "Cluster" && ref.Kind == capi.GroupVersion.Identifier() { + return ref.Name, nil + } + } + + return "", nil +} diff --git a/main.go b/main.go index 8a1d690c2..33eaa3de6 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "github.com/rancher-sandbox/rancher-turtles/feature" "github.com/rancher-sandbox/rancher-turtles/internal/controllers" managementv3 "github.com/rancher-sandbox/rancher-turtles/internal/rancher/management/v3" provisioningv1 "github.com/rancher-sandbox/rancher-turtles/internal/rancher/provisioning/v1" @@ -185,6 +186,19 @@ func setupReconcilers(ctx context.Context, mgr ctrl.Manager) { setupLog.Error(err, "unable to create capi controller") os.Exit(1) } + + if feature.Gates.Enabled(feature.V2ProvKcfgPatch) { + setupLog.Info("enabling v2prov kubeconfig secret patching") + + if err := (&controllers.V2ProvKcfgSecretReconciler{ + Client: mgr.GetClient(), + RancherClient: rancherClient, + WatchFilterValue: watchFilterValue, + }).SetupWithManager(ctx, mgr, controller.Options{MaxConcurrentReconciles: concurrencyNumber}); err != nil { + setupLog.Error(err, "unable to create v2prov kcfg secret controller") + os.Exit(1) + } + } } // setupRancherClient can either create a client for an in-cluster installation (rancher and rancher-turtles in the same cluster) diff --git a/util/predicates/naming_redicates.go b/util/predicates/naming_redicates.go new file mode 100644 index 000000000..c0987d4ce --- /dev/null +++ b/util/predicates/naming_redicates.go @@ -0,0 +1,59 @@ +/* +Copyright 2023 SUSE. + +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 predicates + +import ( + "strings" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// NameHasSuffix returns a predicate that checks the name of the object has a specific suffix. +func NameHasSuffix(logger logr.Logger, suffix string) predicate.Funcs { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return processIfNameHasSuffix(logger.WithValues("predicate", "NameHasSuffix", "eventType", "update"), e.ObjectNew, suffix) + }, + CreateFunc: func(e event.CreateEvent) bool { + return processIfNameHasSuffix(logger.WithValues("predicate", "NameHasSuffix", "eventType", "create"), e.Object, suffix) + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return processIfNameHasSuffix(logger.WithValues("predicate", "NameHasSuffix", "eventType", "delete"), e.Object, suffix) + }, + GenericFunc: func(e event.GenericEvent) bool { + return processIfNameHasSuffix(logger.WithValues("predicate", "NameHasSuffix", "eventType", "generic"), e.Object, suffix) + }, + } +} + +func processIfNameHasSuffix(logger logr.Logger, obj client.Object, suffix string) bool { + kind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) + log := logger.WithValues("namespace", obj.GetNamespace(), kind, obj.GetName()) + + if strings.HasSuffix(obj.GetName(), suffix) { + log.V(4).Info("Object name has suffix, will attempt to map", "object", obj) + + return true + } + + log.V(4).Info("Object name doesn't have suffix, will not map resource", "object", obj) + + return false +} diff --git a/util/predicates/v2prov_predicates.go b/util/predicates/v2prov_predicates.go new file mode 100644 index 000000000..c24d90ad9 --- /dev/null +++ b/util/predicates/v2prov_predicates.go @@ -0,0 +1,65 @@ +/* +Copyright 2023 SUSE. + +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 predicates + +import ( + "strings" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + provisioningv1 "github.com/rancher-sandbox/rancher-turtles/internal/rancher/provisioning/v1" +) + +// V2ProvClusterOwned returns a predicate that checks for a v2prov cluster owner reference. +func V2ProvClusterOwned(logger logr.Logger) predicate.Funcs { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return processIfV2ProvOwned(logger.WithValues("predicate", "V2ProvClusterOwned", "eventType", "update"), e.ObjectNew) + }, + CreateFunc: func(e event.CreateEvent) bool { + return processIfV2ProvOwned(logger.WithValues("predicate", "V2ProvClusterOwned", "eventType", "create"), e.Object) + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + } +} + +func processIfV2ProvOwned(logger logr.Logger, obj client.Object) bool { + kind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) + log := logger.WithValues("namespace", obj.GetNamespace(), kind, obj.GetName()) + + ownerRefs := obj.GetOwnerReferences() + for _, ref := range ownerRefs { + if ref.Kind == provisioningv1.GroupVersion.Identifier() { + if ref.Kind != "Cluster" { + log.V(4).Info("Object is owned by v2prov cluster, will attempt to map", "object", obj) + return true + } + } + } + + log.V(4).Info("No owner reference for v2prov cluster, will not map resource", "object", obj) + + return false +}