Skip to content

Commit

Permalink
[BOP-86] Add status & events for Helm Chart Addons; Improve Status fo…
Browse files Browse the repository at this point in the history
…r Manifests (#18)

* WIP : Status for helmcharts

* Rework statuses for manfiests to watch any Deployments/Daemonsets for status updates

* Reduce number of reconcile calls due to manifest status hopping between Unhealthy & Progressing during Deployment progression; General cleanup

* Improve comments, cleanup code

* Only check for manfiest cluster status if we are not updating the manifest itself

* Refactor; Address review comments

* Fix to work with newest controller runtime version after rebase
  • Loading branch information
tppolkow authored Dec 8, 2023
1 parent 9799a35 commit 3c32600
Show file tree
Hide file tree
Showing 3 changed files with 389 additions and 46 deletions.
42 changes: 42 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,48 @@ rules:
verbs:
- create
- patch
- apiGroups:
- apps
resources:
- daemonsets
verbs:
- get
- list
- watch
- apiGroups:
- apps
resources:
- daemonsets/status
verbs:
- get
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- list
- watch
- apiGroups:
- apps
resources:
- deployments/status
verbs:
- get
- apiGroups:
- batch
resources:
- jobs
verbs:
- get
- list
- watch
- apiGroups:
- batch
resources:
- jobs/status
verbs:
- get
- apiGroups:
- boundless.mirantis.com
resources:
Expand Down
188 changes: 169 additions & 19 deletions controllers/addon_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,37 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/go-logr/logr"
boundlessv1alpha1 "github.com/mirantis/boundless-operator/api/v1alpha1"
"github.com/mirantis/boundless-operator/pkg/event"
"github.com/mirantis/boundless-operator/pkg/helm"
"github.com/mirantis/boundless-operator/pkg/manifest"
batch "k8s.io/api/batch/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
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/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

boundlessv1alpha1 "github.com/mirantis/boundless-operator/api/v1alpha1"
"github.com/mirantis/boundless-operator/pkg/event"
"github.com/mirantis/boundless-operator/pkg/helm"
"github.com/mirantis/boundless-operator/pkg/manifest"
)

const (
kindManifest = "manifest"
kindChart = "chart"
BoundlessNamespace = "boundless-system"
addonHelmchartFinalizer = "boundless.mirantis.com/helmchart-finalizer"
addonManifestFinalizer = "boundless.mirantis.com/manifest-finalizer"
addonIndexName = "helmchartIndex"
helmJobNameTemplate = "helm-install-%s"
)

// AddonReconciler reconciles a Addon object
Expand All @@ -39,6 +47,10 @@ type AddonReconciler struct {
//+kubebuilder:rbac:groups=boundless.mirantis.com,resources=addons,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=boundless.mirantis.com,resources=addons/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=boundless.mirantis.com,resources=addons/finalizers,verbs=update
//+kubebuilder:rbac:groups=boundless.mirantis.com,resources=manifests,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=boundless.mirantis.com,resources=manifests/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch
//+kubebuilder:rbac:groups=batch,resources=jobs/status,verbs=get
//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch

// Reconcile is part of the main kubernetes reconciliation loop which aims to
Expand Down Expand Up @@ -81,8 +93,6 @@ func (r *AddonReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
kind = instance.Spec.Kind
}

// @TODO: Update addon status only once per reconcile; React to Statuses of HelmChart / Manifests

switch kind {
case kindChart:
if instance.Spec.Chart == nil {
Expand Down Expand Up @@ -137,11 +147,22 @@ func (r *AddonReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
if err := hc.CreateHelmChart(chart, instance.Spec.Namespace); err != nil {
logger.Error(err, "failed to install addon", "Name", chart.Name, "Version", chart.Version)
r.Recorder.AnnotatedEventf(instance, map[string]string{event.AddonAnnotationKey: instance.Name}, event.TypeWarning, event.ReasonFailedCreate, "Failed to Create Chart Addon %s/%s : %s", instance.Spec.Namespace, instance.Name, err)
r.updateStatus(ctx, logger, req.NamespacedName, boundlessv1alpha1.TypeComponentUnhealthy, "Failed to Create HelmChart")
return ctrl.Result{Requeue: true}, err
}
r.Recorder.AnnotatedEventf(instance, map[string]string{event.AddonAnnotationKey: instance.Name}, event.TypeNormal, event.ReasonSuccessfulCreate, "Created Chart Addon %s/%s", instance.Spec.Namespace, instance.Name)
r.updateStatus(ctx, logger, req.NamespacedName, boundlessv1alpha1.TypeComponentAvailable, "Chart Addon Created")

// unfortunately the HelmChart CR doesn't have any useful events or status we can monitor
// each helmchart object creates a job that runs the helm install - update status from that instead
jobName := fmt.Sprintf(helmJobNameTemplate, instance.Spec.Chart.Name)
job := &batch.Job{}
err = r.Get(ctx, types.NamespacedName{Namespace: instance.Spec.Namespace, Name: jobName}, job)
if err != nil {
// might need some time for helmchart CR to create job
return ctrl.Result{RequeueAfter: 10 * time.Second}, err
}

if err := r.updateHelmchartAddonStatus(ctx, logger, req.NamespacedName, job, instance); err != nil {
return ctrl.Result{RequeueAfter: 10 * time.Second}, err
}

case kindManifest:
if instance.Spec.Manifest == nil {
Expand All @@ -165,11 +186,10 @@ func (r *AddonReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
// The object is being deleted
if controllerutil.ContainsFinalizer(instance, addonManifestFinalizer) {
// our finalizer is present, so lets delete the helm chart
if err := mc.DeleteManifest(BoundlessNamespace, instance.Spec.Name, instance.Spec.Manifest.URL); err != nil {
if err := mc.DeleteManifest(boundlessSystemNamespace, instance.Spec.Name, instance.Spec.Manifest.URL); err != nil {
// if fail to delete the manifest here, return with error
// so that it can be retried
r.Recorder.AnnotatedEventf(instance, map[string]string{event.AddonAnnotationKey: instance.Name}, event.TypeWarning, event.ReasonFailedDelete, "Failed to Delete Manifest Addon %s/%s : %s", instance.Spec.Namespace, instance.Name, err)
r.updateStatus(ctx, logger, req.NamespacedName, boundlessv1alpha1.TypeComponentUnhealthy, "Failed to Cleanup Manifest")
return ctrl.Result{}, err
}

Expand All @@ -184,16 +204,32 @@ func (r *AddonReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
return ctrl.Result{}, nil
}

err = mc.CreateManifest(BoundlessNamespace, instance.Spec.Name, instance.Spec.Manifest.URL)
err = mc.CreateManifest(boundlessSystemNamespace, instance.Spec.Name, instance.Spec.Manifest.URL)
if err != nil {
logger.Error(err, "failed to install addon via manifest", "URL", instance.Spec.Manifest.URL)
r.Recorder.AnnotatedEventf(instance, map[string]string{event.AddonAnnotationKey: instance.Name}, event.TypeWarning, event.ReasonFailedCreate, "Failed to Create Manifest Addon %s/%s : %s", instance.Spec.Namespace, instance.Name, err)
r.updateStatus(ctx, logger, req.NamespacedName, boundlessv1alpha1.TypeComponentUnhealthy, "Failed to Create Manifest")
return ctrl.Result{Requeue: true}, err
}

r.Recorder.AnnotatedEventf(instance, map[string]string{event.AddonAnnotationKey: instance.Name}, event.TypeNormal, event.ReasonSuccessfulCreate, "Created Manifest Addon %s/%s", instance.Spec.Namespace, instance.Name)
r.updateStatus(ctx, logger, req.NamespacedName, boundlessv1alpha1.TypeComponentAvailable, "Manifest Addon Created")
m := &boundlessv1alpha1.Manifest{}
err = r.Get(ctx, types.NamespacedName{Namespace: boundlessSystemNamespace, Name: instance.Spec.Name}, m)
if err != nil {
if errors.IsNotFound(err) {
// might need some time for CR to be created
r.updateStatus(ctx, logger, req.NamespacedName, boundlessv1alpha1.TypeComponentProgressing, "Awaiting Manifest Resource Creation")
}
return ctrl.Result{}, err
}

result, err := r.setOwnerReferenceOnManifest(ctx, logger, instance, m)
if err != nil {
return result, err
}

err = r.updateManifestAddonStatus(ctx, logger, instance, m)
if err != nil {
return result, err
}

default:
logger.Info("Unknown AddOn kind", "Kind", instance.Spec.Kind)
Expand All @@ -204,22 +240,136 @@ func (r *AddonReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl
return ctrl.Result{Requeue: false}, nil
}

// updateManifestAddonStatus checks if the manifest associated with the addon has a status to bubble up to addon and updates addon if so
func (r *AddonReconciler) updateManifestAddonStatus(ctx context.Context, logger logr.Logger, addon *boundlessv1alpha1.Addon, manifest *boundlessv1alpha1.Manifest) error {
if manifest.Status.Type == "" || manifest.Status.Reason == "" {
err := r.updateStatus(ctx, logger, types.NamespacedName{Namespace: addon.Namespace, Name: addon.Name}, boundlessv1alpha1.TypeComponentProgressing, "Awaiting status from manifest object")
if err != nil {
return err
}
// manifest has no status yet so nothing to do
return nil
}

if manifest.Status.Type == boundlessv1alpha1.TypeComponentAvailable && addon.Status.Type != boundlessv1alpha1.TypeComponentAvailable {
// we are about to update the addon status from not available to available so let's emit an event
r.Recorder.AnnotatedEventf(addon, map[string]string{event.AddonAnnotationKey: addon.Name}, event.TypeNormal, event.ReasonSuccessfulCreate, "Created Manifest Addon %s/%s", addon.Spec.Namespace, addon.Name)
}

err := r.updateStatus(ctx, logger, types.NamespacedName{Namespace: addon.Namespace, Name: addon.Name}, manifest.Status.Type, manifest.Status.Reason, manifest.Status.Message)
if err != nil {
return err
}
return nil
}

// setOwnerReferenceOnManifest sets the owner reference on the manifest object to point to the addon object
// This effectively causes the owner addon to be reconciled when the manifest is updated.
func (r *AddonReconciler) setOwnerReferenceOnManifest(ctx context.Context, logger logr.Logger, addon *boundlessv1alpha1.Addon, manifest *boundlessv1alpha1.Manifest) (ctrl.Result, error) {
logger.Info("Set owner ref field on manifest")
if err := controllerutil.SetControllerReference(addon, manifest, r.Scheme); err != nil {
logger.Error(err, "Failed to set owner reference on manifest", "ManifestName", manifest.Name)
return ctrl.Result{}, err
}

if err := r.Update(ctx, manifest); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *AddonReconciler) SetupWithManager(mgr ctrl.Manager) error {
// attaches an index onto the Addon
// This is done so we can later easily find the addon associated with a particular job
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &boundlessv1alpha1.Addon{}, addonIndexName, func(rawObj client.Object) []string {
addon := rawObj.(*boundlessv1alpha1.Addon)
if isHelmChartAddon(addon) {
jobName := fmt.Sprintf(helmJobNameTemplate, addon.Spec.Chart.Name)
return []string{fmt.Sprintf("%s-%s", addon.Spec.Namespace, jobName)}
}
// don't add this index for non helm-chart type addons
return nil
}); err != nil {
return err
}

return ctrl.NewControllerManagedBy(mgr).
For(&boundlessv1alpha1.Addon{}).
Owns(&boundlessv1alpha1.Manifest{}).
Watches(
&batch.Job{}, // Watch all Job Objects in the cluster
handler.EnqueueRequestsFromMapFunc(r.findAddonForJob), // All jobs trigger this MapFunc, the MapFunc filters which jobs should trigger reconciles to which addons, if any
builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), // By default, any Update to job will trigger a run of the MapFunc, limit it to only Resource version updates
).
Complete(r)
}

func (r *AddonReconciler) updateStatus(ctx context.Context, logger logr.Logger, namespacedName types.NamespacedName, conditionTypeToApply boundlessv1alpha1.StatusType, reasonToApply string, messageToApply ...string) error {
// isHelmChartAddon checks the provided addon's spec and determines whether this addon is a chart kind
func isHelmChartAddon(addon *boundlessv1alpha1.Addon) bool {
return addon.Spec.Chart != nil && addon.Spec.Chart.Name != ""
}

// findAddonForJob finds the addons associated with a particular job
// This is done by looking for the addon that was previously indexed in the form jobNamespace-jobName
func (r *AddonReconciler) findAddonForJob(ctx context.Context, job client.Object) []reconcile.Request {
attachedAddonList := &boundlessv1alpha1.AddonList{}
err := r.List(context.TODO(), attachedAddonList, client.MatchingFields{addonIndexName: fmt.Sprintf("%s-%s", job.GetNamespace(), job.GetName())})
if err != nil {
return []reconcile.Request{}
}

requests := make([]reconcile.Request, len(attachedAddonList.Items))
for i, item := range attachedAddonList.Items {
requests[i] = reconcile.Request{
NamespacedName: types.NamespacedName{
Name: item.GetName(),
Namespace: item.GetNamespace(),
},
}
}
return requests
}

// updateHelmchartAddonStatus checks the status of the associated helm chart job and updates the status of the Addon CR accordingly
func (r *AddonReconciler) updateHelmchartAddonStatus(ctx context.Context, logger logr.Logger, namespacedName types.NamespacedName, job *batch.Job, addon *boundlessv1alpha1.Addon) error {
logger.Info("Updating Helm Chart Addon Status")
if job.Status.CompletionTime != nil && job.Status.Succeeded > 0 {
r.Recorder.AnnotatedEventf(addon, map[string]string{event.AddonAnnotationKey: addon.Name}, event.TypeNormal, event.ReasonSuccessfulCreate, "Created Chart Addon %s/%s", addon.Spec.Namespace, addon.Name)
err := r.updateStatus(ctx, logger, namespacedName, boundlessv1alpha1.TypeComponentAvailable, fmt.Sprintf("Helm Chart %s successfully installed", job.Name))
if err != nil {
return err
}
} else if job.Status.StartTime != nil && job.Status.Failed > 0 {
r.Recorder.AnnotatedEventf(addon, map[string]string{event.AddonAnnotationKey: addon.Name}, event.TypeWarning, event.ReasonFailedCreate, "Helm Chart Addon %s/%s has failed to install", addon.Spec.Namespace, addon.Name)
err := r.updateStatus(ctx, logger, namespacedName, boundlessv1alpha1.TypeComponentUnhealthy, fmt.Sprintf("Helm Chart %s install has failed", job.Name))
if err != nil {
return err
}
} else {
err := r.updateStatus(ctx, logger, namespacedName, boundlessv1alpha1.TypeComponentProgressing, fmt.Sprintf("Helm Chart %s install still progressing", job.Name))
if err != nil {
return err
}
}
return nil
}

// updateStatus queries for a fresh Addon with the provided namespacedName.
// This avoids some errors where we fail to update status because we have an older (stale) version of the object
// It then updates the Addon's status fields with the provided type, reason, and optionally message.
func (r *AddonReconciler) updateStatus(ctx context.Context, logger logr.Logger, namespacedName types.NamespacedName, typeToApply boundlessv1alpha1.StatusType, reasonToApply string, messageToApply ...string) error {
logger.Info("Update status with type and reason", "TypeToApply", typeToApply, "ReasonToApply", reasonToApply)

addon := &boundlessv1alpha1.Addon{}
err := r.Get(ctx, namespacedName, addon)
if err != nil {
logger.Error(err, "Failed to get addon to update status")
return err
}

if addon.Status.Type == conditionTypeToApply && addon.Status.Reason == reasonToApply {
nilStatus := boundlessv1alpha1.AddonStatus{}
if addon.Status != nilStatus && addon.Status.Type == typeToApply && addon.Status.Reason == reasonToApply {
// avoid infinite reconciliation loops
logger.Info("No updates to status needed")
return nil
Expand All @@ -228,7 +378,7 @@ func (r *AddonReconciler) updateStatus(ctx context.Context, logger logr.Logger,
logger.Info("Update status for addon", "Name", addon.Name)

patch := client.MergeFrom(addon.DeepCopy())
addon.Status.Type = conditionTypeToApply
addon.Status.Type = typeToApply
addon.Status.Reason = reasonToApply
if len(messageToApply) > 0 {
addon.Status.Message = messageToApply[0]
Expand Down
Loading

0 comments on commit 3c32600

Please sign in to comment.