Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
drupalsite_controller_utils.go 14.67 KiB
/*
Copyright 2021 CERN.

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"
	"crypto/md5"
	"encoding/hex"
	"fmt"
	"time"

	"github.com/go-logr/logr"
	buildv1 "github.com/openshift/api/build/v1"
	pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1"
	velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
	dbodv1a1 "gitlab.cern.ch/drupal/paas/dbod-operator/api/v1alpha1"
	webservicesv1a1 "gitlab.cern.ch/drupal/paas/drupalsite-operator/api/v1alpha1"
	appsv1 "k8s.io/api/apps/v1"
	batchv1 "k8s.io/api/batch/v1"
	corev1 "k8s.io/api/core/v1"
	k8sapierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
	"knative.dev/pkg/apis"
	controllerruntime "sigs.k8s.io/controller-runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)

// getBuildStatus gets the build status from one of the builds for a given resources
func (r *DrupalSiteReconciler) getBuildStatus(ctx context.Context, resource string, drp *webservicesv1a1.DrupalSite) (buildv1.BuildPhase, error) {
	buildList := &buildv1.BuildList{}
	buildLabels, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{
		MatchLabels: map[string]string{"openshift.io/build-config.name": resource + nameVersionHash(drp)},
	})
	if err != nil {
		return "", newApplicationError(err, ErrFunctionDomain)
	}
	options := client.ListOptions{
		LabelSelector: buildLabels,
		Namespace:     drp.Namespace,
	}
	err = r.List(ctx, buildList, &options)
	if err != nil {
		return "", newApplicationError(err, ErrClientK8s)
	}
	// Check for one more build?
	if len(buildList.Items) > 0 {
		return buildList.Items[len(buildList.Items)-1].Status.Phase, nil
	}
	return "", newApplicationError(err, ErrClientK8s)
}

// isInstallJobCompleted checks if the drush job is successfully completed
func (r *DrupalSiteReconciler) isInstallJobCompleted(ctx context.Context, d *webservicesv1a1.DrupalSite) bool {
	found := &batchv1.Job{}
	jobObject := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "ensure-site-install-" + d.Name, Namespace: d.Namespace}}
	err := r.Get(ctx, types.NamespacedName{Name: jobObject.Name, Namespace: jobObject.Namespace}, found)
	if err == nil {
		if found.Status.Succeeded != 0 {
			return true
		}
	}
	return false
}

// isCloneJobCompleted checks if the clone job is successfully completed
func (r *DrupalSiteReconciler) isCloneJobCompleted(ctx context.Context, d *webservicesv1a1.DrupalSite) bool {
	cloneJob := &batchv1.Job{}
	err := r.Get(ctx, types.NamespacedName{Name: "clone-" + d.Name, Namespace: d.Namespace}, cloneJob)
	if err != nil {
		return false
	}
	// business logic, ie check "Succeeded"
	return cloneJob.Status.Succeeded != 0
}

// isEasystartTaskRunCompleted checks if the easystart taskRun is successfully completed
func (r *DrupalSiteReconciler) isEasystartTaskRunCompleted(ctx context.Context, d *webservicesv1a1.DrupalSite) bool {
	easystartTaskRun := &pipelinev1.TaskRun{}
	err := r.Get(ctx, types.NamespacedName{Name: "easystart-" + d.Name, Namespace: d.Namespace}, easystartTaskRun)
	if err != nil {
		return false
	}
	// business logic, ie check "Succeeded"
	return easystartTaskRun.Status.GetCondition(apis.ConditionSucceeded).IsTrue()
}

// isDrupalSiteReady checks if the drupal site is to ready to serve requests by checking the status of Nginx & PHP pods
func (r *DrupalSiteReconciler) isDrupalSiteReady(ctx context.Context, d *webservicesv1a1.DrupalSite) bool {
	deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: d.Name, Namespace: d.Namespace}}
	err1 := r.Get(ctx, types.NamespacedName{Name: deployment.Name, Namespace: deployment.Namespace}, deployment)
	if err1 == nil {
		// Change the implementation here
		if deployment.Status.ReadyReplicas != 0 {
			return true
		}
	}
	return false
}

// isDrupalSiteInstalled checks if the drupal site is initialized by running drush status command in the PHP pod
func (r *DrupalSiteReconciler) isDrupalSiteInstalled(ctx context.Context, d *webservicesv1a1.DrupalSite) bool {
	if r.isDrupalSiteReady(ctx, d) {
		if _, err := r.execToServerPodErrOnStderr(ctx, d, "php-fpm", nil, checkIfSiteIsInstalled()...); err != nil {
			return false
		}
		return true
	}
	return false
}

// isDBODProvisioned checks if the DBOD has been provisioned by checking the status of DBOD custom resource
func (r *DrupalSiteReconciler) isDBODProvisioned(ctx context.Context, d *webservicesv1a1.DrupalSite) bool {
	database := &dbodv1a1.Database{}
	err := r.Get(ctx, types.NamespacedName{Name: d.Name, Namespace: d.Namespace}, database)
	if err != nil {
		return false
	}
	return len(database.Status.DbodInstance) > 0
}

// databaseSecretName fetches the secret name of the DBOD provisioned secret by checking the status of DBOD custom resource
func databaseSecretName(d *webservicesv1a1.DrupalSite) string {
	return "dbcredentials-" + d.Name
}

// cleanupDrupalSite checks and removes if a finalizer exists on the resource
func (r *DrupalSiteReconciler) cleanupDrupalSite(ctx context.Context, log logr.Logger, drp *webservicesv1a1.DrupalSite) (ctrl.Result, error) {
	log.V(1).Info("Deleting DrupalSite")

	controllerutil.RemoveFinalizer(drp, finalizerStr)
	if err := r.ensureNoBackupSchedule(ctx, drp, log); err != nil {
		return ctrl.Result{}, err
	}
	return r.updateCRorFailReconcile(ctx, log, drp)
}

// ensureSpecFinalizer ensures that the spec is valid, adding extra info if necessary, and that the finalizer is there,
// then returns if it needs to be updated.
func (r *DrupalSiteReconciler) ensureSpecFinalizer(ctx context.Context, drp *webservicesv1a1.DrupalSite, log logr.Logger) (update bool, err reconcileError) {
	// We want the update variable to be true, only when we make changes to the CR & want it to be updated
	if !controllerutil.ContainsFinalizer(drp, finalizerStr) {
		log.V(3).Info("Adding finalizer")
		controllerutil.AddFinalizer(drp, finalizerStr)
		update = true
	}
	if drp.Spec.Configuration.WebDAVPassword == "" {
		drp.Spec.Configuration.WebDAVPassword = generateRandomPassword()
		update = true
	}
	// Set default value for DiskSize to 2000Mi
	if drp.Spec.Configuration.DiskSize == "" {
		drp.Spec.Configuration.DiskSize = "2000Mi"
		update = true
	}

	// Validate that CloneFrom is an existing DrupalSite
	if drp.Spec.Configuration.CloneFrom != "" {
		if !drp.ConditionTrue("Initialized") {
			sourceSite := webservicesv1a1.DrupalSite{}
			err := r.Get(ctx, types.NamespacedName{Name: string(drp.Spec.Configuration.CloneFrom), Namespace: drp.Namespace}, &sourceSite)
			switch {
			case k8sapierrors.IsNotFound(err):
				return false, newApplicationError(fmt.Errorf("CloneFrom DrupalSite doesn't exist"), ErrInvalidSpec)
			case err != nil:
				return false, newApplicationError(err, ErrClientK8s)
			}

			// The destination disk size must be at least as large as the source
			if drp.Spec.Configuration.DiskSize != sourceSite.Spec.Configuration.DiskSize {
				drp.Spec.Configuration.DiskSize = sourceSite.Spec.Configuration.DiskSize
				update = true
			}
			// The extraConfigurationRepo should be set in the clone site if defined in the source
			// TODO: Remove logic for ExtraConfigurationRepo once we deprecate the field
			if sourceSite.Spec.Configuration.ExtraConfigurationRepo != "" && drp.Spec.Configuration.ExtraConfigurationRepo == "" {
				drp.Spec.Configuration.ExtraConfigurationRepo = sourceSite.Spec.Configuration.ExtraConfigurationRepo
				update = true
			}
			// The extraConfigurationRepository should be set in the clone site if defined in the source
			if sourceSite.Spec.Configuration.ExtraConfigurationRepository.Branch != "" && sourceSite.Spec.Configuration.ExtraConfigurationRepository.RepositoryUrl != "" && drp.Spec.Configuration.ExtraConfigurationRepository.Branch == "" && drp.Spec.Configuration.ExtraConfigurationRepository.RepositoryUrl != "" {
				drp.Spec.Configuration.ExtraConfigurationRepository.Branch = sourceSite.Spec.Configuration.ExtraConfigurationRepository.Branch
				drp.Spec.Configuration.ExtraConfigurationRepository.RepositoryUrl = sourceSite.Spec.Configuration.ExtraConfigurationRepository.RepositoryUrl
				update = true
			}
		} else {
			if drp.Annotations == nil {
				drp.Annotations = map[string]string{}
			}
			// Source site name from which the clone was requested
			drp.Annotations["clonedFrom"] = string(drp.Spec.Configuration.CloneFrom)
			// Set the time when the clone was requested
			loc, _ := time.LoadLocation("Europe/Zurich")
			drp.Annotations["clonedOn"] = time.Now().In(loc).Format("Jan 2 2006 3:04PM CET")
			// Reset the `cloneFrom` field
			drp.Spec.Configuration.CloneFrom = ""
			update = true
		}
	}

	// Initialize 'spec.version.releaseSpec' if empty
	if len(drp.Spec.Version.ReleaseSpec) == 0 {
		// Fetch the SupportedDrupalVersions instance
		supportedDrupalVersions := &webservicesv1a1.SupportedDrupalVersions{}
		namespacedName := types.NamespacedName{Name: SupportedDrupalVersionName, Namespace: ""}
		err := r.Get(ctx, namespacedName, supportedDrupalVersions)
		if err != nil {
			if k8sapierrors.IsNotFound(err) {
				// Request object not found, could have been deleted after reconcile request.
				// Owned objects are automatically garbage collected. For additional cleanup logic use finalizers.
				// Return and don't requeue
				log.V(3).Info(fmt.Sprintf("SupportedDrupalVersions '%s' resource not found. Ignoring since object must be deleted", SupportedDrupalVersionName))
				return update, nil
			}
			// Error reading the object - fail the request.
			log.Error(err, fmt.Sprintf("Failed to get SupportedDrupalVersions '%s'", SupportedDrupalVersionName))
			return false, newApplicationError(err, ErrClientK8s)
		}
		// Iterate over available versions to find if the one requested has a ReleaseSpec
		for _, v := range supportedDrupalVersions.Status.AvailableVersions {
			if drp.Spec.Name == v.Name {
				drp.Spec.Version.ReleaseSpec = v.LatestReleaseSpec
				update = true
				break
			}
		}
		// If no available ReleaseSpec, we log and don't update
		if drp.Spec.Version.ReleaseSpec == "" {
			log.V(3).Info(fmt.Sprintf("Failed to get a ReleaseSpec for version %s", drp.Spec.Version.Name))
			return false, nil
		}
	}
	return update, nil
}

func (r *DrupalSiteReconciler) getDeployConfigmap(ctx context.Context, d *webservicesv1a1.DrupalSite) (deploy appsv1.Deployment,
	cmPhp corev1.ConfigMap, cmNginxGlobal corev1.ConfigMap, cmSettings corev1.ConfigMap, cmPhpCli corev1.ConfigMap, err error) {
	err = r.Get(ctx, types.NamespacedName{Name: d.Name, Namespace: d.Namespace}, &deploy)
	if err != nil {
		return
	}
	err = r.Get(ctx, types.NamespacedName{Name: "php-fpm-" + d.Name, Namespace: d.Namespace}, &cmPhp)
	if err != nil {
		return
	}
	err = r.Get(ctx, types.NamespacedName{Name: "nginx-global-" + d.Name, Namespace: d.Namespace}, &cmNginxGlobal)
	if err != nil {
		return
	}
	err = r.Get(ctx, types.NamespacedName{Name: "site-settings-" + d.Name, Namespace: d.Namespace}, &cmSettings)
	if err != nil {
		return
	}
	err = r.Get(ctx, types.NamespacedName{Name: "php-cli-config-" + d.Name, Namespace: d.Namespace}, &cmSettings)
	return
}

// ensureDeploymentConfigmapHash ensures that the deployment has annotations with the content of each configmap.
// If the content of the configmaps changes, this will ensure that the deployemnt rolls out.
func (r *DrupalSiteReconciler) ensureDeploymentConfigmapHash(ctx context.Context, d *webservicesv1a1.DrupalSite, log logr.Logger) (requeue bool, transientErr reconcileError) {
	deploy, cmPhp, cmNginxGlobal, cmSettings, cmPhpCli, err := r.getDeployConfigmap(ctx, d)
	switch {
	case k8sapierrors.IsNotFound(err):
		return false, nil
	case err != nil:
		return false, newApplicationError(err, ErrClientK8s)
	}
	updateDeploymentAnnotations := func(deploy *appsv1.Deployment, d *webservicesv1a1.DrupalSite) error {
		hashPhp := md5.Sum([]byte(createKeyValuePairs(cmPhp.Data)))
		hashNginxGlobal := md5.Sum([]byte(createKeyValuePairs(cmNginxGlobal.Data)))
		hashSettings := md5.Sum([]byte(createKeyValuePairs(cmSettings.Data)))
		hashPhpCli := md5.Sum([]byte(createKeyValuePairs(cmPhpCli.Data)))

		deploy.Spec.Template.ObjectMeta.Annotations["phpfpm-configmap/hash"] = hex.EncodeToString(hashPhp[:])
		deploy.Spec.Template.ObjectMeta.Annotations["nginx-configmap/hash"] = hex.EncodeToString(hashNginxGlobal[:])
		deploy.Spec.Template.ObjectMeta.Annotations["settings.php-configmap/hash"] = hex.EncodeToString(hashSettings[:])
		deploy.Spec.Template.ObjectMeta.Annotations["php-cli-configmap/hash"] = hex.EncodeToString(hashPhpCli[:])
		return nil
	}
	_, err = controllerruntime.CreateOrUpdate(ctx, r.Client, &deploy, func() error {
		return updateDeploymentAnnotations(&deploy, d)
	})
	switch {
	case k8sapierrors.IsConflict(err):
		log.V(4).Info("Server deployment changed while reconciling. Requeuing.")
		return true, nil
	case err != nil:
		return false, newApplicationError(fmt.Errorf("failed to annotate deployment with configmap hashes: %w", err), ErrClientK8s)
	}
	return false, nil
}

// checkNewBackups returns the list of velero backups that exist for a given site
func (r *DrupalSiteReconciler) checkNewBackups(ctx context.Context, d *webservicesv1a1.DrupalSite, log logr.Logger) (backups []webservicesv1a1.Backup, reconcileErr reconcileError) {
	backupList := velerov1.BackupList{}
	backups = make([]webservicesv1a1.Backup, 0)
	hash := md5.Sum([]byte(d.Namespace))
	backupLabels, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{
		MatchLabels: map[string]string{"drupal.webservices.cern.ch/projectHash": hex.EncodeToString(hash[:])},
	})
	if err != nil {
		reconcileErr = newApplicationError(err, ErrFunctionDomain)
		return
	}
	options := client.ListOptions{
		LabelSelector: backupLabels,
		Namespace:     VeleroNamespace,
	}
	err = r.List(ctx, &backupList, &options)
	switch {
	case err != nil:
		reconcileErr = newApplicationError(err, ErrClientK8s)
	case len(backupList.Items) == 0:
		log.V(3).Info("No backup found with given labels " + backupLabels.String())
	default:
		for i := range backupList.Items {
			if backupList.Items[i].Status.Phase == velerov1.BackupPhaseCompleted {
				backups = append(backups, webservicesv1a1.Backup{BackupName: backupList.Items[i].Name, Date: backupList.Items[i].Status.CompletionTimestamp, Expires: backupList.Items[i].Status.Expiration, DrupalSiteName: backupList.Items[i].Annotations["drupal.webservices.cern.ch/drupalSite"]})
			}
		}
	}
	return
}