/* Copyright 2022. 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 clientctl import ( "context" "errors" "fmt" "net/http" "time" apiv1 "k8s.io/api/core/v1" apierrs "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" kclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/predicate" logger "sigs.k8s.io/controller-runtime/pkg/log" "github.com/go-kivik/kivik/v4" "github.com/go-kivik/kivik/v4/couchdb" "github.com/go-logr/logr" api "edge-infra.dev/pkg/edge/iam/api/v1alpha1" iamclient "edge-infra.dev/pkg/edge/iam/client" "edge-infra.dev/pkg/edge/iam/config" "edge-infra.dev/pkg/edge/iam/storage/database" "edge-infra.dev/pkg/k8s/runtime/controller/metrics" "edge-infra.dev/pkg/lib/logging" ) const ( EdgeIDFinalizer = "finalizers.id.edge.ncr.com" ClientRegistrationSucceeded = "ClientRegistrationSucceeded" MissingSecretData = "MissingSecretData" ClientRegistrationFailed = "ClientRegistrationFailed" ClientSecretExistFailure = "ClientSecretExistFailure" ClientSecretCreationFailure = "ClientSecretCreationFailure" SaveClientCredentailsFailed = "SaveClientCredentailsFailed" CouchDBNotReady = "CouchDBNotReady" ) // ClientReconciler reconciles a Client object type ClientReconciler struct { kclient.Client Scheme *runtime.Scheme Name string // kubebuilder default metrics Metrics metrics.Metrics } var clientStorer iamclient.Storage //+kubebuilder:rbac:groups=iam.edge-infra.dev,resources=clients,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=iam.edge-infra.dev,resources=clients/status,verbs=get;update;patch //+kubebuilder:rbac:groups=iam.edge-infra.dev,resources=clients/finalizers,verbs=update // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // TODO(user): Modify the Reconcile function to compare the state specified by // the Client object against the actual cluster state, and then // perform operations to make the cluster state reflect the state specified by // the user. // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.11.2/pkg/reconcile func (r *ClientReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) { var ( reconcileStart = time.Now() log = logger.FromContext(ctx) client = api.Client{} ) log.V(2).Info("reconcile client", "req", req) // try to get the client resource if err := r.Get(ctx, req.NamespacedName, &client); err != nil { // failed to get client resource return ctrl.Result{}, kclient.IgnoreNotFound(err) } defer func() { r.Metrics.RecordReadiness(ctx, &client) r.Metrics.RecordDuration(ctx, &client, reconcileStart) }() if !controllerutil.ContainsFinalizer(&client, EdgeIDFinalizer) { controllerutil.AddFinalizer(&client, EdgeIDFinalizer) // return with requeue, so we can reconcile again with finalizer. err := r.Update(ctx, &client) if err != nil { log.Error(err, "error updating client resource after adding finalizer") } return ctrl.Result{Requeue: true}, nil } if !client.ObjectMeta.DeletionTimestamp.IsZero() { err = r.finalize(ctx, req, &client) if err != nil { log.Error(err, "error executing finalizer on client resource") return ctrl.Result{Requeue: true}, nil } return ctrl.Result{}, nil } var reconciled api.Client up, reconcileErr := isCouchDBUp() if up { // client resource exists... // reconcile the client, creating a copy so that we avoid mutating our controller's cache reconciled, reconcileErr = r.reconcile(ctx, req, *client.DeepCopy()) } else { reconciled = api.MarkNotReady(reconciled, CouchDBNotReady, reconcileErr.Error()) log.Info("database not ready yet, will try again soon", "error", reconcileErr) } // reflect the reconciled status on the API server if err := r.updateClientStatus(ctx, req, reconciled.Status); err != nil { log.Info("failed to update client status. will retry soon", "status", reconciled.Status) return ctrl.Result{Requeue: true}, nil } // if reconciliation errors occur, return them and requeue so we re-try // if there are reconciliation errors that are nonrecoverable, we can handle // them here by setting Requeue: false conditionally if !up { return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } if reconcileErr != nil { log.Error(reconcileErr, "error reconciling client resource") return ctrl.Result{Requeue: true}, nil } return ctrl.Result{}, nil } func (r *ClientReconciler) finalize(ctx context.Context, req ctrl.Request, client *api.Client) error { log := logr.FromContextOrDiscard(ctx) log.Info("running finalizer") log.Info("unregistering client from DB") // this client resource does not exist, make sure to remove it from db too if unregisterErr := r.unregisterClient(ctx, req); unregisterErr != nil { return unregisterErr } controllerutil.RemoveFinalizer(client, EdgeIDFinalizer) err := r.Update(ctx, client) if err != nil { return err } log.Info("executed finalizer") return nil } // SetupWithManager sets up the controller with the Manager. func (r *ClientReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&api.Client{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Complete(r) } func (r *ClientReconciler) reconcile(ctx context.Context, req ctrl.Request, client api.Client) (api.Client, error) { var secret *apiv1.Secret var err error log := logger.FromContext(ctx) client, secret, err = r.reconcileClientSecret(ctx, req, client) if err != nil { return client, err } // secret exists, let's validate it client, err = r.validateClientSecret(client, secret) if err != nil { log.Error(err, fmt.Sprintf("secret %s/%s is invalid", secret.Name, secret.Namespace)) return client, err } // save the secret to storage err = r.saveClientCredentials(ctx, secret) if err != nil { return api.MarkNotReady(client, SaveClientCredentailsFailed, err.Error()), err } // we have a valid client secret, let's now make sure the client is registered... client, err = r.registerClient(ctx, req, client, secret) if err != nil { return client, err } // reconciled without errors.. return api.MarkReady(client, ClientRegistrationSucceeded, "successfully registered the client"), nil } func clientSecretExists(ctx context.Context, req ctrl.Request, c kclient.Client, secretName string) (*apiv1.Secret, error) { clientSecret := &apiv1.Secret{} err := c.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: secretName}, clientSecret) switch { case err == nil: return clientSecret, nil case apierrs.IsNotFound(err): return nil, nil default: return nil, fmt.Errorf("failed to check if client secret exists: %w", err) } } func (r *ClientReconciler) validateClientSecret(client api.Client, secret *apiv1.Secret) (api.Client, error) { _, found := secret.Data["client_id"] if !found { e := errors.New(`"client_id property missing"`) return api.MarkNotReady(client, MissingSecretData, e.Error()), e } _, found = secret.Data["client_secret"] if !found { e := errors.New(`"client_secret property missing"`) return api.MarkNotReady(client, MissingSecretData, e.Error()), e } return client, nil } // isCouchDBUp returns true if we can ping couchDB func isCouchDBUp() (bool, error) { log := logging.NewLogger().WithName("isCouchDBUp") couchURI := config.CouchDBAddress() client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} couchDBClient, err := kivik.New("couch", couchURI, couchdb.BasicAuth(config.CouchDBUser(), config.CouchDBPassword()), couchdb.OptionHTTPClient(client)) if err != nil { return false, err } defer func(couchDBClient *kivik.Client) { err := couchDBClient.Close() if err != nil { log.Error(err, "Failed to close CouchDB client") } }(couchDBClient) online, err := couchDBClient.Ping(context.Background()) if err != nil { log.Error(err, "couchClient.Ping error") } return online, err } func getClientStorer(log logr.Logger) (iamclient.Storage, error) { if clientStorer == nil { var err error clientStorer, err = database.NewOperatorStore(log) if err != nil { return nil, err } } return clientStorer, nil } // updateClientStatus fetches an up-to-date copy of the object we want to update the // status for and patches its status. // this is done to minimize cache mutation errors and API server mismatch errors // that can occur if the patch does not align with the API server's current state func (r *ClientReconciler) updateClientStatus(ctx context.Context, req ctrl.Request, status api.ClientStatus) error { var c api.Client if err := r.Get(ctx, req.NamespacedName, &c); err != nil { return err } patch := kclient.MergeFrom(c.DeepCopy()) c.Status = status return r.Status().Patch(ctx, &c, patch) }