diff --git a/controllers/step_status_reconciler.go b/controllers/step_status_reconciler.go new file mode 100644 index 0000000000000000000000000000000000000000..df2b8970aaf7b59f8b2873bbd3a7de94ecbbcb38 --- /dev/null +++ b/controllers/step_status_reconciler.go @@ -0,0 +1,84 @@ +package controllers + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + api "github.com/smallstep/step-issuer/api/v1beta1" + core "k8s.io/api/core/v1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type stepStatusReconciler struct { + *StepIssuerReconciler + issuer *api.StepIssuer + logger logr.Logger +} + +func newStepStatusReconciler(r *StepIssuerReconciler, iss *api.StepIssuer, log logr.Logger) *stepStatusReconciler { + return &stepStatusReconciler{ + StepIssuerReconciler: r, + issuer: iss, + logger: log, + } +} + +func (r *stepStatusReconciler) Update(ctx context.Context, status api.ConditionStatus, reason, message string, args ...interface{}) error { + completeMessage := fmt.Sprintf(message, args...) + r.setCondition(status, reason, completeMessage) + + // Fire an Event to additionally inform users of the change + eventType := core.EventTypeNormal + if status == api.ConditionFalse { + eventType = core.EventTypeWarning + } + r.Recorder.Event(r.issuer, eventType, reason, completeMessage) + + return r.Client.Update(ctx, r.issuer) +} + +// setCondition will set a 'condition' on the given api.StepIssuer resource. +// +// - If no condition of the same type already exists, the condition will be +// inserted with the LastTransitionTime set to the current time. +// - If a condition of the same type and state already exists, the condition +// will be updated but the LastTransitionTime will not be modified. +// - If a condition of the same type and different state already exists, the +// condition will be updated and the LastTransitionTime set to the current +// time. +func (r *stepStatusReconciler) setCondition(status api.ConditionStatus, reason, message string) { + now := meta.NewTime(r.Clock.Now()) + c := api.StepIssuerCondition{ + Type: api.ConditionReady, + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: &now, + } + + // Search through existing conditions + for idx, cond := range r.issuer.Status.Conditions { + // Skip unrelated conditions + if cond.Type != api.ConditionReady { + continue + } + + // If this update doesn't contain a state transition, we don't update + // the conditions LastTransitionTime to Now() + if cond.Status == status { + c.LastTransitionTime = cond.LastTransitionTime + } else { + r.logger.Info("found status change for StepIssuer condition; setting lastTransitionTime", "condition", cond.Type, "old_status", cond.Status, "new_status", status, "time", now.Time) + } + + // Overwrite the existing condition + r.issuer.Status.Conditions[idx] = c + return + } + + // If we've not found an existing condition of this type, we simply insert + // the new condition into the slice. + r.issuer.Status.Conditions = append(r.issuer.Status.Conditions, c) + r.logger.Info("setting lastTransitionTime for StepIssuer condition", "condition", api.ConditionReady, "time", now.Time) +} diff --git a/controllers/stepissuer_controller.go b/controllers/stepissuer_controller.go new file mode 100644 index 0000000000000000000000000000000000000000..04abdd8b9da049a11838a4bcc1d06c728aac19da --- /dev/null +++ b/controllers/stepissuer_controller.go @@ -0,0 +1,122 @@ +/* + +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" + + "github.com/go-logr/logr" + api "github.com/smallstep/step-issuer/api/v1beta1" + "github.com/smallstep/step-issuer/provisioners" + core "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/utils/clock" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// StepIssuerReconciler reconciles a StepIssuer object +type StepIssuerReconciler struct { + client.Client + Log logr.Logger + Clock clock.Clock + Recorder record.EventRecorder +} + +// +kubebuilder:rbac:groups=certmanager.step.sm,resources=stepissuers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=certmanager.step.sm,resources=stepissuers/status,verbs=get;update;patch + +// Reconcile will read and validate the StepIssuer resources, it will set the +// status condition ready to true if everything is right. +func (r *StepIssuerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { + ctx := context.Background() + log := r.Log.WithValues("stepissuer", req.NamespacedName) + + iss := new(api.StepIssuer) + if err := r.Client.Get(ctx, req.NamespacedName, iss); err != nil { + log.Error(err, "failed to retrieve StepIssuer resource") + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + statusReconciler := newStepStatusReconciler(r, iss, log) + if err := validateStepIssuerSpec(iss.Spec); err != nil { + log.Error(err, "failed to validate StepIssuer resource") + statusReconciler.Update(ctx, api.ConditionFalse, "Validation", "Failed to validate resource: %v", err) + return ctrl.Result{}, err + } + + // Fetch the provisioner password + var secret core.Secret + secretNamespaceName := types.NamespacedName{ + Namespace: req.Namespace, + Name: iss.Spec.Provisioner.PasswordRef.Name, + } + if err := r.Client.Get(ctx, secretNamespaceName, &secret); err != nil { + log.Error(err, "failed to retrieve StepIssuer provisioner secret", "namespace", secretNamespaceName.Namespace, "name", secretNamespaceName.Name) + if apierrors.IsNotFound(err) { + statusReconciler.Update(ctx, api.ConditionFalse, "NotFound", "Failed to retrieve provisioner secret: %v", err) + } else { + statusReconciler.Update(ctx, api.ConditionFalse, "Error", "Failed to retrieve provisioner secret: %v", err) + } + return ctrl.Result{}, err + } + password, ok := secret.Data[iss.Spec.Provisioner.PasswordRef.Key] + if !ok { + err := fmt.Errorf("secret %s does not contain key %s", secret.Name, iss.Spec.Provisioner.PasswordRef.Key) + log.Error(err, "failed to retrieve StepIssuer provisioner secret", "namespace", secretNamespaceName.Namespace, "name", secretNamespaceName.Name) + statusReconciler.Update(ctx, api.ConditionFalse, "NotFound", "Failed to retrieve provisioner secret: %v", err) + return ctrl.Result{}, err + } + + // Initialize and store the provisioner + p, err := provisioners.New(iss, password) + if err != nil { + log.Error(err, "failed to initialize provisioner") + statusReconciler.Update(ctx, api.ConditionFalse, "Error", "failed initialize provisioner") + return ctrl.Result{}, err + } + provisioners.Store(req.NamespacedName, p) + + return ctrl.Result{}, statusReconciler.Update(ctx, api.ConditionTrue, "Verified", "StepIssuer verified and ready to sign certificates") +} + +// SetupWithManager initializes the StepIssuer controller into the controller +// runtime. +func (r *StepIssuerReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&api.StepIssuer{}). + Complete(r) +} + +func validateStepIssuerSpec(s api.StepIssuerSpec) error { + switch { + case s.URL == "": + return fmt.Errorf("spec.url cannot be empty") + case s.Provisioner.Name == "": + return fmt.Errorf("spec.provisioner.name cannot be empty") + case s.Provisioner.KeyID == "": + return fmt.Errorf("spec.provisioner.kid cannot be empty") + case s.Provisioner.PasswordRef.Name == "": + return fmt.Errorf("spec.provisioner.passwordRef.name cannot be empty") + case s.Provisioner.PasswordRef.Key == "": + return fmt.Errorf("spec.provisioner.passwordRef.key cannot be empty") + default: + return nil + } +}