diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 72465b4dd..5fc69bdcc 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -23,14 +23,18 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/containers/image/v5/types" "github.com/go-logr/logr" "github.com/spf13/pflag" + corev1 "k8s.io/api/core/v1" apiextensionsv1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" + "k8s.io/apimachinery/pkg/fields" k8slabels "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" + k8stypes "k8s.io/apimachinery/pkg/types" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/klog/v2" @@ -69,7 +73,7 @@ var ( defaultSystemNamespace = "olmv1-system" ) -const authFilePath = "/etc/operator-controller/auth.json" +const authFilePath = "/tmp/operator-controller/auth.json" // podNamespace checks whether the controller is running in a Pod vs. // being run locally by inspecting the namespace file that gets mounted @@ -92,6 +96,7 @@ func main() { operatorControllerVersion bool systemNamespace string caCertDir string + globalPullSecret string ) flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -102,6 +107,7 @@ func main() { flag.StringVar(&cachePath, "cache-path", "/var/cache", "The local directory path used for filesystem based caching") flag.BoolVar(&operatorControllerVersion, "version", false, "Prints operator-controller version information") flag.StringVar(&systemNamespace, "system-namespace", "", "Configures the namespace that gets used to deploy system resources.") + flag.StringVar(&globalPullSecret, "global-pull-secret", "", "The namespace/name of the global pull secret that is going to be used to pull bundle images.") klog.InitFlags(flag.CommandLine) @@ -118,6 +124,16 @@ func main() { setupLog.Info("starting up the controller", "version info", version.String()) + var globalPullSecretKey *k8stypes.NamespacedName + if globalPullSecret != "" { + secretParts := strings.Split(globalPullSecret, "/") + if len(secretParts) != 2 { + setupLog.Error(fmt.Errorf("incorrect number of components"), "value of global-pull-secret should be of the format /") + os.Exit(1) + } + globalPullSecretKey = &k8stypes.NamespacedName{Name: secretParts[1], Namespace: secretParts[0]} + } + if systemNamespace == "" { systemNamespace = podNamespace() } @@ -130,22 +146,33 @@ func main() { dependentSelector := k8slabels.NewSelector().Add(*dependentRequirement) setupLog.Info("set up manager") + cacheOptions := crcache.Options{ + ByObject: map[client.Object]crcache.ByObject{ + &ocv1alpha1.ClusterExtension{}: {Label: k8slabels.Everything()}, + &catalogd.ClusterCatalog{}: {Label: k8slabels.Everything()}, + }, + DefaultNamespaces: map[string]crcache.Config{ + systemNamespace: {LabelSelector: k8slabels.Everything()}, + }, + DefaultLabelSelector: dependentSelector, + } + if globalPullSecretKey != nil { + cacheOptions.ByObject[&corev1.Secret{}] = crcache.ByObject{ + Field: fields.SelectorFromSet(map[string]string{ + "metadata.name": globalPullSecretKey.Name, + }), + } + cacheOptions.DefaultNamespaces[globalPullSecretKey.Namespace] = crcache.Config{ + LabelSelector: k8slabels.Everything(), + } + } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme.Scheme, Metrics: server.Options{BindAddress: metricsAddr}, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "9c4404e7.operatorframework.io", - Cache: crcache.Options{ - ByObject: map[client.Object]crcache.ByObject{ - &ocv1alpha1.ClusterExtension{}: {Label: k8slabels.Everything()}, - &catalogd.ClusterCatalog{}: {Label: k8slabels.Everything()}, - }, - DefaultNamespaces: map[string]crcache.Config{ - systemNamespace: {LabelSelector: k8slabels.Everything()}, - }, - DefaultLabelSelector: dependentSelector, - }, + Cache: cacheOptions, // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily // when the Manager ends. This requires the binary to immediately end when the // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly @@ -290,6 +317,19 @@ func main() { os.Exit(1) } + if globalPullSecretKey != nil { + setupLog.Info("creating SecretSyncer controller for watching secret", "Secret", globalPullSecret) + err := (&controllers.SecretSyncerReconciler{ + Client: mgr.GetClient(), + AuthFilePath: authFilePath, + SecretKey: *globalPullSecretKey, + }).SetupWithManager(mgr) + if err != nil { + setupLog.Error(err, "unable to create controller", "controller", "SecretSyncer") + os.Exit(1) + } + } + //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/internal/controllers/secretsyncer_controller.go b/internal/controllers/secretsyncer_controller.go new file mode 100644 index 000000000..88dccf106 --- /dev/null +++ b/internal/controllers/secretsyncer_controller.go @@ -0,0 +1,124 @@ +/* +Copyright 2024. + +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" + "os" + "path/filepath" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// SecretSyncerReconciler reconciles a specific secret object +type SecretSyncerReconciler struct { + client.Client + SecretKey types.NamespacedName + AuthFilePath string +} + +func (r *SecretSyncerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + if req.Name != r.SecretKey.Name || req.Namespace != r.SecretKey.Namespace { + logger.Error(fmt.Errorf("received unexpected request for Secret %v/%v", req.Namespace, req.Name), "reconciliation error") + return ctrl.Result{}, nil + } + + secret := &corev1.Secret{} + err := r.Get(ctx, req.NamespacedName, secret) + if err != nil { + if apierrors.IsNotFound(err) { + logger.Info("secret not found") + return r.deleteSecretFile(logger) + } + logger.Error(err, "failed to get Secret") + return ctrl.Result{}, err + } + + return r.writeSecretToFile(logger, secret) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *SecretSyncerReconciler) SetupWithManager(mgr ctrl.Manager) error { + _, err := ctrl.NewControllerManagedBy(mgr). + For(&corev1.Secret{}). + WithEventFilter(newSecretPredicate(r.SecretKey)). + Build(r) + + return err +} + +func newSecretPredicate(key types.NamespacedName) predicate.Predicate { + return predicate.NewPredicateFuncs(func(obj client.Object) bool { + return obj.GetName() == key.Name && obj.GetNamespace() == key.Namespace + }) +} + +// writeSecretToFile writes the secret data to the specified file +func (r *SecretSyncerReconciler) writeSecretToFile(logger logr.Logger, secret *corev1.Secret) (ctrl.Result, error) { + // image registry secrets are always stored with the key .dockerconfigjson + // ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/#registry-secret-existing-credentials + dockerConfigJSON, ok := secret.Data[".dockerconfigjson"] + if !ok { + logger.Error(fmt.Errorf("expected secret.Data key not found"), "expected secret Data to contain key .dockerconfigjson") + return ctrl.Result{}, nil + } + // expected format for auth.json + // https://github.com/containers/image/blob/main/docs/containers-auth.json.5.md + if err := r.writeDataToFile(dockerConfigJSON); err != nil { + return ctrl.Result{}, err + } + logger.Info("saved Secret data locally", "file", r.AuthFilePath) + return ctrl.Result{}, nil +} + +// deleteSecretFile deletes the auth file if the secret is deleted +func (r *SecretSyncerReconciler) deleteSecretFile(logger logr.Logger) (ctrl.Result, error) { + logger.Info("deleting local auth file", "file", r.AuthFilePath) + if err := os.Remove(r.AuthFilePath); err != nil { + if os.IsNotExist(err) { + logger.Info("auth file does not exist, nothing to delete", "file", r.AuthFilePath) + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("failed to delete secret file: %w", err) + } else { + logger.Info("auth file deleted successfully", "file", r.AuthFilePath) + } + return ctrl.Result{}, nil +} + +func (r *SecretSyncerReconciler) writeDataToFile(data []byte) error { + // create the directory first if it does not exists + dir := filepath.Dir(r.AuthFilePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("unable to create directory for storing auth: %w", err) + + } + err := os.WriteFile(r.AuthFilePath, data, 0600) + if err != nil { + return fmt.Errorf("failed to write secret data to file: %w", err) + } + return nil +} diff --git a/internal/controllers/secretsyncer_controller_test.go b/internal/controllers/secretsyncer_controller_test.go new file mode 100644 index 000000000..a10d97bed --- /dev/null +++ b/internal/controllers/secretsyncer_controller_test.go @@ -0,0 +1,97 @@ +package controllers_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/operator-framework/operator-controller/internal/controllers" + "github.com/operator-framework/operator-controller/internal/scheme" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestSecretSyncerReconciler(t *testing.T) { + secretData := []byte(`{"auths":{"exampleRegistry": "exampledata"}}`) + authFileName := "test-auth.json" + for _, tt := range []struct { + name string + secret *corev1.Secret + addSecret bool + wantErr string + fileShouldExistBefore bool + fileShouldExistAfter bool + }{ + { + name: "secret exists, content gets saved to authFile", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-secret-namespace", + }, + Data: map[string][]byte{ + ".dockerconfigjson": secretData, + }, + }, + addSecret: true, + fileShouldExistBefore: false, + fileShouldExistAfter: true, + }, + { + name: "secret does not exist, file exists previously, file should get deleted", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-secret-namespace", + }, + Data: map[string][]byte{ + ".dockerconfigjson": secretData, + }, + }, + addSecret: false, + fileShouldExistBefore: true, + fileShouldExistAfter: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + tempAuthFile := filepath.Join(t.TempDir(), authFileName) + clientBuilder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + if tt.addSecret { + clientBuilder = clientBuilder.WithObjects(tt.secret) + } + cl := clientBuilder.Build() + + secretKey := types.NamespacedName{Namespace: tt.secret.Namespace, Name: tt.secret.Name} + r := &controllers.SecretSyncerReconciler{ + Client: cl, + SecretKey: secretKey, + AuthFilePath: tempAuthFile, + } + if tt.fileShouldExistBefore { + err := os.WriteFile(tempAuthFile, secretData, 0600) + require.NoError(t, err) + } + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: secretKey}) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + require.Equal(t, ctrl.Result{}, res) + + if tt.fileShouldExistAfter { + _, err := os.Stat(tempAuthFile) + require.NoError(t, err) + } else { + _, err := os.Stat(tempAuthFile) + require.True(t, os.IsNotExist(err)) + } + }) + } +}