diff --git a/apis/extension/v1/backup_types.go b/apis/extension/v1/backup_types.go index 5c1a1398..c85122b3 100644 --- a/apis/extension/v1/backup_types.go +++ b/apis/extension/v1/backup_types.go @@ -34,7 +34,6 @@ type BackupSpec struct { type BackupStatus struct { StartTime *metav1.Time `json:"startTime,omitempty"` CompletionTime *metav1.Time `json:"completionTime,omitempty"` - ResticID string `json:"resticId,omitempty"` Phase shpmetav1.Phase `json:"phase"` } diff --git a/apis/meta/v1/types.go b/apis/meta/v1/types.go index 6d1408cb..e560b572 100644 --- a/apis/meta/v1/types.go +++ b/apis/meta/v1/types.go @@ -63,9 +63,6 @@ const ( // ReplaceConcurrent cancels currently running job and replaces it with a new one. ReplaceConcurrent ConcurrencyPolicy = "Replace" - - // FriendlyNameFormat is the time format for default friendly name. - FriendlyNameFormat = "Mon, 02/01/2006 - 15:04" ) // ScheduledAnnotation is used to detect the time when an object was scheduled. diff --git a/controllers/extension/backup/controller.go b/controllers/extension/backup/controller.go index e5bc7eba..1642ea24 100644 --- a/controllers/extension/backup/controller.go +++ b/controllers/extension/backup/controller.go @@ -1,39 +1,15 @@ -/* -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 backup import ( "context" "fmt" - "io/ioutil" - "time" "github.com/go-logr/logr" "github.com/go-test/deep" - "github.com/pkg/errors" - batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" - kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -42,44 +18,77 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" extensionv1 "github.com/universityofadelaide/shepherd-operator/apis/extension/v1" - v1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" - "github.com/universityofadelaide/shepherd-operator/internal/events" - jobutils "github.com/universityofadelaide/shepherd-operator/internal/k8s/job" - "github.com/universityofadelaide/shepherd-operator/internal/restic" - sliceutils "github.com/universityofadelaide/shepherd-operator/internal/slice" + shpdmetav1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" + awscli "github.com/universityofadelaide/shepherd-operator/internal/aws/cli" + podutils "github.com/universityofadelaide/shepherd-operator/internal/k8s/pod" ) const ( - // ControllerName used for identifying which controller is performing an operation. - ControllerName = "backup-restic-controller" - // Finalizer used by this controller to perform a final operation on object deletion. - Finalizer = "backups.finalizers.shepherd" + // ControllerName is used to identify this controller in logs and events. + ControllerName = "backup-controller" + + // EnvAWSAccessKeyID for authentication. + EnvAWSAccessKeyID = "AWS_ACCESS_KEY_ID" + // EnvAWSSecretAccessKey for authentication. + EnvAWSSecretAccessKey = "AWS_SECRET_ACCESS_KEY" + // EnvAWSRegion for authentication. + EnvAWSRegion = "AWS_DEFAULT_REGION" + + // EnvMySQLHostname for MySQL connection. + EnvMySQLHostname = "MYSQL_HOSTNAME" + // EnvMySQLDatabase for MySQL connection. + EnvMySQLDatabase = "MYSQL_DATABASE" + // EnvMySQLPort for MySQL connection. + EnvMySQLPort = "MYSQL_PORT" + // EnvMySQLUsername for MySQL connection. + EnvMySQLUsername = "MYSQL_USERNAME" + // EnvMySQLPassword for MySQL connection. + EnvMySQLPassword = "MYSQL_PASSWORD" + + // VolumeMySQL identifier for mysql storage. + VolumeMySQL = "mysql" ) // Reconciler reconciles a Backup object type Reconciler struct { client.Client - Config *rest.Config Recorder record.EventRecorder Scheme *runtime.Scheme Params Params } -// Params which are provided to this controller. +// Params used by this controller. type Params struct { - // Parameters which are used when provisioning a Pod instance. - PodSpec restic.PodSpecParams + ResourceRequirements corev1.ResourceRequirements + WorkingDir string + // MySQL params used by this controller. + MySQL MySQL + // AWS params used by this controller. + AWS AWS +} + +// MySQL params used by this controller. +type MySQL struct { + Image string +} + +// AWS params used by this controller. +type AWS struct { + Endpoint string + BucketName string + Image string + FieldKeyID string + FieldAccessKey string + Region string } -//+kubebuilder:rbac:groups=v1,resources=pods,verbs=get;list -//+kubebuilder:rbac:groups=v1,resources=pods/log,verbs=get -//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=batch,resources=jobs/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=batch,resources=jobs/finalizers,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=batch,resources=pods,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=batch,resources=pods/status,verbs=get //+kubebuilder:rbac:groups=extension.shepherd,resources=backups,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=extension.shepherd,resources=backups/status,verbs=get;update;patch //+kubebuilder:rbac:groups=extension.shepherd,resources=backups/finalizers,verbs=update +// Reconcile a Backup object func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -87,306 +96,293 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu backup := &extensionv1.Backup{} - err := r.Get(ctx, req.NamespacedName, backup) - if err != nil { + if err := r.Get(ctx, req.NamespacedName, backup); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - if backup.ObjectMeta.DeletionTimestamp.IsZero() { - // The object is not being deleted, if it does not have this finalizer, - // add it and update the object. - if !sliceutils.Contains(backup.ObjectMeta.Finalizers, Finalizer) { - logger.Info("Adding finalizer") - - backup.ObjectMeta.Finalizers = append(backup.ObjectMeta.Finalizers, Finalizer) - if err := r.Update(ctx, backup); err != nil { - return reconcile.Result{Requeue: true}, nil - } - } - } else { - // The object is being deleted, ensure that the finalizer exists then - // create a job to delete the restic snapshot. - if sliceutils.Contains(backup.ObjectMeta.Finalizers, Finalizer) { - // Check status of restic-delete job. - newJob := &batchv1.Job{} - - nsn := types.NamespacedName{ - Namespace: backup.Namespace, - Name: fmt.Sprintf("%s-delete-%s", restic.Prefix, backup.Name), - } - - err := r.Get(ctx, nsn, newJob) - if err != nil { - if !kerrors.IsNotFound(err) { - return reconcile.Result{Requeue: true}, err - } - - logger.Info("Forgetting the Restic snapshot", "id", backup.Status.ResticID) - - if backup.Status.ResticID == "" { - // Allow the backup to delete when we don't know the restic id. - logger.Info("No restic ID associated when attempting to delete backup", "name", backup.ObjectMeta.Name) - return r.removeFinalizer(ctx, backup) - } else { - // Job doesnt exist, create it. - err := r.DeleteResticSnapshot(ctx, backup) - return reconcile.Result{RequeueAfter: 5 * time.Second}, err - } - } - - if jobutils.IsFinished(newJob) { - logger.Info("Removing finalizer", "finalizer", Finalizer) - return r.removeFinalizer(ctx, backup) - } - - logger.Info("Requeuing to wait for finalizer Job to finish") - - return reconcile.Result{RequeueAfter: 5 * time.Second}, nil - } - - return reconcile.Result{}, nil - } - // Backup has completed or failed, return early. - if backup.Status.Phase == v1.PhaseCompleted || backup.Status.Phase == v1.PhaseFailed { + if backup.Status.Phase == shpdmetav1.PhaseCompleted || backup.Status.Phase == shpdmetav1.PhaseFailed { return reconcile.Result{}, nil } - if _, found := backup.ObjectMeta.GetLabels()["site"]; !found { - // @todo add some info to the status identifying the backup failed - logger.Info(fmt.Sprintf("Backup %s doesn't have a site label, skipping.", backup.ObjectMeta.Name)) - return reconcile.Result{}, nil + secret, err := r.createSecret(ctx, backup, r.Params.AWS.FieldKeyID, r.Params.AWS.FieldAccessKey) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create Secret: %w", err) } - return r.SyncJob(ctx, logger, backup) -} - -// SyncJob creates or updates the restic backup jobs. -func (r *Reconciler) SyncJob(ctx context.Context, log logr.Logger, backup *extensionv1.Backup) (reconcile.Result, error) { - // Backup has completed or failed, return early. - if backup.Status.Phase == v1.PhaseCompleted || backup.Status.Phase == v1.PhaseFailed { - return reconcile.Result{}, nil + status, err := r.createPod(ctx, backup, secret) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create Pod: %w", err) } - spec, err := restic.PodSpecBackup(backup, r.Params.PodSpec, backup.ObjectMeta.GetLabels()["site"]) + err = r.updateStatus(ctx, logger, backup, status) if err != nil { - return reconcile.Result{}, err + return ctrl.Result{}, fmt.Errorf("failed to update Backup status: %w", err) } - var ( - parallelism int32 = 1 - completions int32 = 1 - activeDeadline int64 = 3600 - backOffLimit int32 = 2 - ) + logger.Info("Finished reconcile loop") - job := &batchv1.Job{ + return ctrl.Result{}, nil +} + +// Creates Secret object based on the provided Spec configuration. +func (r *Reconciler) createSecret(ctx context.Context, backup *extensionv1.Backup, key, access string) (*corev1.Secret, error) { + secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%s", restic.Prefix, backup.ObjectMeta.Name), + Name: fmt.Sprintf("backup-%s", backup.ObjectMeta.Name), Namespace: backup.ObjectMeta.Namespace, - Labels: map[string]string{ - "app": "restic", - "resticAction": "backup", - }, }, - Spec: batchv1.JobSpec{ - Parallelism: ¶llelism, - Completions: &completions, - ActiveDeadlineSeconds: &activeDeadline, - BackoffLimit: &backOffLimit, - Template: corev1.PodTemplateSpec{ - Spec: spec, - }, + Data: map[string][]byte{ + EnvAWSAccessKeyID: []byte(key), + EnvAWSSecretAccessKey: []byte(access), }, } - log.Info("Syncing Job") - - if err := controllerutil.SetControllerReference(backup, job, r.Scheme); err != nil { - return reconcile.Result{}, err - } - - if err := r.Create(ctx, job); client.IgnoreNotFound(err) != nil { - return reconcile.Result{}, err + if err := controllerutil.SetControllerReference(backup, secret, r.Scheme); err != nil { + return nil, err } - if err = r.Get(ctx, types.NamespacedName{ - Namespace: job.ObjectMeta.Namespace, - Name: job.ObjectMeta.Name, - }, job); err != nil { - return ctrl.Result{}, err + if err := r.Create(ctx, secret); client.IgnoreNotFound(err) != nil { + return nil, err } - log.Info("Syncing status") + return secret, nil +} - status := extensionv1.BackupStatus{ - Phase: v1.PhaseNew, - StartTime: job.Status.StartTime, - CompletionTime: job.Status.CompletionTime, +// Creates Pod objects based on the provided Spec configuration. +func (r *Reconciler) createPod(ctx context.Context, backup *extensionv1.Backup, secret *corev1.Secret) (extensionv1.BackupStatus, error) { + cmd := awscli.CommandParams{ + Endpoint: r.Params.AWS.Endpoint, + Service: "s3", + Operation: "sync", + Args: []string{ + ".", fmt.Sprintf("s3://%s/%s/%s", r.Params.AWS.BucketName, backup.ObjectMeta.Namespace, backup.ObjectMeta.Name), + }, } - if job.Status.Active > 0 { - status.Phase = v1.PhaseInProgress - } else { - if job.Status.Succeeded > 0 { - resticId, err := getResticIdFromJob(ctx, r.Config, job) - if err != nil { - return reconcile.Result{}, errors.Wrap(err, "failed to parse resticId") - } - - if resticId != "" { - status.ResticID = resticId - status.Phase = v1.PhaseCompleted - } else { - status.Phase = v1.PhaseFailed - } - } - if job.Status.Failed > 0 { - status.Phase = v1.PhaseFailed - } + // @todo, This should be configured at the object level. + exclude := []string{ + "volume/*/*/php", + "volume/*/*/css", + "volume/*/*/js", } - if diff := deep.Equal(backup.Status, status); diff != nil { - log.Info(fmt.Sprintf("Status change dectected: %s", diff)) - - backup.Status = status - - err := r.Status().Update(ctx, backup) - if err != nil { - return reconcile.Result{}, errors.Wrap(err, "failed to update status") - } + for _, exclude := range exclude { + cmd.Args = append(cmd.Args, "--exclude", exclude) } - log.Info("Reconcile finished") - - return reconcile.Result{}, nil -} - -// DeleteResticSnapshot creates the job to forget a restic snapshot. -func (r *Reconciler) DeleteResticSnapshot(ctx context.Context, backup *extensionv1.Backup) error { - if backup.Status.ResticID == "" { - return errors.Errorf("Could't delete restic snapshot. Restic ID missing for backup: %s", backup.ObjectMeta.Name) + // Container responsible for uploading database and files to AWS S3. + upload := corev1.Container{ + Name: "aws-s3-sync", + Image: r.Params.AWS.Image, + ImagePullPolicy: corev1.PullAlways, + Resources: r.Params.ResourceRequirements, + WorkingDir: r.Params.WorkingDir, + Command: []string{ + "bash", + "-c", + }, + Args: awscli.Command(cmd), + Env: []corev1.EnvVar{ + { + Name: EnvAWSAccessKeyID, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secret.ObjectMeta.Name, + }, + Key: EnvAWSAccessKeyID, + }, + }, + }, + { + Name: EnvAWSSecretAccessKey, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secret.ObjectMeta.Name, + }, + Key: EnvAWSSecretAccessKey, + }, + }, + }, + { + Name: EnvAWSRegion, + Value: r.Params.AWS.Region, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: VolumeMySQL, + MountPath: fmt.Sprintf("%s/mysql", r.Params.WorkingDir), + }, + }, } - spec, err := restic.PodSpecDelete( - backup.Status.ResticID, - backup.ObjectMeta.Namespace, - backup.ObjectMeta.GetLabels()["site"], - r.Params.PodSpec, - ) - if err != nil { - return err + for volumeName := range backup.Spec.Volumes { + upload.VolumeMounts = append(upload.VolumeMounts, corev1.VolumeMount{ + Name: fmt.Sprintf("volume-%s", volumeName), + MountPath: fmt.Sprintf("%s/volume/%s", r.Params.WorkingDir, volumeName), + ReadOnly: true, + }) } - var ( - parallelism int32 = 1 - completions int32 = 1 - activeDeadline int64 = 3600 - backOffLimit int32 = 2 - //ttl int32 = 3600 - ) - - job := &batchv1.Job{ + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-delete-%s", restic.Prefix, backup.Name), + Name: fmt.Sprintf("backup-%s", backup.ObjectMeta.Name), Namespace: backup.ObjectMeta.Namespace, - Labels: map[string]string{ - "app": "restic", - "resticAction": "delete", - }, }, - Spec: batchv1.JobSpec{ - // @todo uncomment this when the feature becomes available (requires kube v1.12+). - // ttlSecondsAfterFinished: &ttl, - Parallelism: ¶llelism, - Completions: &completions, - ActiveDeadlineSeconds: &activeDeadline, - BackoffLimit: &backOffLimit, - Template: corev1.PodTemplateSpec{ - Spec: spec, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + upload, + }, + Volumes: []corev1.Volume{ + { + Name: VolumeMySQL, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumDefault, + }, + }, + }, }, }, } - result, err := controllerutil.CreateOrUpdate(ctx, r.Client, job, func() error { - return nil - }) - if err != nil { - return err + for volumeName, volumeSpec := range backup.Spec.Volumes { + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ + Name: fmt.Sprintf("volume-%s", volumeName), + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: volumeSpec.ClaimName, + }, + }, + }) } - switch result { - case controllerutil.OperationResultCreated: - r.Recorder.Eventf(backup, corev1.EventTypeNormal, events.EventCreate, "Job has been created: %s", job.ObjectMeta.Name) - case controllerutil.OperationResultUpdated: - r.Recorder.Eventf(backup, corev1.EventTypeNormal, events.EventUpdate, "Job has been updated: %s", job.ObjectMeta.Name) + for mysqlName, mysqlStatus := range backup.Spec.MySQL { + pod.Spec.InitContainers = append(pod.Spec.InitContainers, corev1.Container{ + Name: fmt.Sprintf("mysql-%s", mysqlName), + Image: r.Params.MySQL.Image, + Resources: r.Params.ResourceRequirements, + WorkingDir: r.Params.WorkingDir, + Command: []string{ + "bash", + "-c", + }, + Args: []string{ + // @todo, Remove hardcoded command and path. + fmt.Sprintf("database-backup > mysql/%s.sql", mysqlName), + }, + Env: []corev1.EnvVar{ + { + Name: EnvMySQLHostname, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Hostname, + }, + }, + }, + { + Name: EnvMySQLDatabase, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Database, + }, + }, + }, + { + Name: EnvMySQLPort, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Port, + }, + }, + }, + { + Name: EnvMySQLUsername, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Username, + }, + }, + }, + { + Name: EnvMySQLPassword, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Password, + }, + }, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: VolumeMySQL, + // @todo, Remove hardcoded mysql path. + MountPath: fmt.Sprintf("%s/mysql", r.Params.WorkingDir), + }, + }, + }) } - return err -} + var status extensionv1.BackupStatus -// getResticIdFromJob parses output from a job's pods and returns a restic ID from the logs. -func getResticIdFromJob(ctx context.Context, config *rest.Config, job *batchv1.Job) (string, error) { - clientset, err := kubernetes.NewForConfig(config) - if err != nil { - return "", err + if err := controllerutil.SetControllerReference(backup, pod, r.Scheme); err != nil { + return status, err } - pods, err := clientset.CoreV1().Pods(job.ObjectMeta.Namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labels.SelectorFromSet(job.Spec.Template.ObjectMeta.Labels).String(), - }) - if err != nil { - return "", err + if err := r.Create(ctx, pod); client.IgnoreNotFound(err) != nil { + return status, err } - for _, pod := range pods.Items { - if pod.Status.Phase != corev1.PodSucceeded { - continue - } - - podLogs, err := getPodLogs(ctx, clientset, pod.ObjectMeta.Namespace, pod.ObjectMeta.Name) - if err != nil { - return "", err - } - - resticId := restic.ParseSnapshotID(podLogs) - if resticId != "" { - return resticId, nil - } + if err := r.Get(ctx, types.NamespacedName{ + Namespace: pod.ObjectMeta.Namespace, + Name: pod.ObjectMeta.Name, + }, pod); err != nil { + return status, err } - return "", nil + status.Phase = podutils.GetPhase(pod.Status) + status.StartTime = pod.Status.StartTime + status.CompletionTime = podutils.CompletionTime(pod) + + return status, nil } -// getPodLogs gets the logs from the restic container from a pod as a string. -func getPodLogs(ctx context.Context, clientset *kubernetes.Clientset, namespace string, podName string) (string, error) { - body, err := clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{ - Container: restic.ResticBackupContainerName, - }).Stream(ctx) - if err != nil { - return "", err +// Update the Backup status. +func (r *Reconciler) updateStatus(ctx context.Context, log logr.Logger, backup *extensionv1.Backup, status extensionv1.BackupStatus) error { + diff := deep.Equal(backup.Status, status) + if diff == nil { + return nil } - defer body.Close() - podLogs, err := ioutil.ReadAll(body) - if err != nil { - return "", err - } + log.Info(fmt.Sprintf("Status change dectected: %s", diff)) - return string(podLogs), nil -} + backup.Status = status -// Helper function to remove the finalizer and exit a reconcile loop. -func (r *Reconciler) removeFinalizer(ctx context.Context, backup *extensionv1.Backup) (reconcile.Result, error) { - backup.ObjectMeta.Finalizers = sliceutils.Remove(backup.ObjectMeta.Finalizers, Finalizer) - err := r.Update(ctx, backup) - return reconcile.Result{}, err + return r.Status().Update(ctx, backup) } -// SetupWithManager sets up the controller with the Manager. +// SetupWithManager will setup the controller. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&extensionv1.Backup{}). - Owns(&batchv1.Job{}). + Owns(&corev1.Pod{}). Complete(r) } diff --git a/controllers/extension/backup/controller_test.go b/controllers/extension/backup/controller_test.go index e1637e2f..c47f1a8c 100644 --- a/controllers/extension/backup/controller_test.go +++ b/controllers/extension/backup/controller_test.go @@ -2,13 +2,9 @@ package backup import ( "context" - "fmt" - "github.com/universityofadelaide/shepherd-operator/internal/restic" "testing" - "time" "github.com/stretchr/testify/assert" - batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -24,7 +20,7 @@ func TestReconcile(t *testing.T) { err := extensionv1.AddToScheme(scheme.Scheme) assert.Nil(t, err) - err = batchv1.AddToScheme(scheme.Scheme) + err = corev1.AddToScheme(scheme.Scheme) assert.Nil(t, err) instance := &extensionv1.Backup{ @@ -46,13 +42,17 @@ func TestReconcile(t *testing.T) { Scheme: scheme.Scheme, Recorder: mockevents.New(), Params: Params{ - PodSpec: restic.PodSpecParams{ - CPU: "500m", - Memory: "512Mi", - ResticImage: "docker.io/restic/restic:0.9.5", - MySQLImage: "skpr/mtk-mysql", - WorkingDir: "/home/shepherd", - Tags: []string{}, + ResourceRequirements: corev1.ResourceRequirements{}, + WorkingDir: "/tmp", + MySQL: MySQL{ + Image: "mysql:latest", + }, + AWS: AWS{ + BucketName: "test", + Image: "aws-cli:latest", + FieldKeyID: "aws.key.id", + FieldAccessKey: "aws.access.key", + Region: "ap-southeast-2", }, }, } @@ -62,64 +62,9 @@ func TestReconcile(t *testing.T) { }) assert.Nil(t, err) - found := &extensionv1.Backup{} - err = rd.Client.Get(context.TODO(), query, found) - assert.Nil(t, err) -} - -func TestReconcileDelete(t *testing.T) { - err := extensionv1.AddToScheme(scheme.Scheme) + list := &corev1.PodList{} + err = rd.Client.List(context.TODO(), list) assert.Nil(t, err) - deletionTimestamp := &metav1.Time{} - deletionTimestamp.Time = time.Now() - instance := &extensionv1.Backup{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: corev1.NamespaceDefault, - DeletionTimestamp: deletionTimestamp, - Finalizers: []string{Finalizer}, - }, - Spec: extensionv1.BackupSpec{}, - Status: extensionv1.BackupStatus{ - ResticID: "test", - }, - } - - // Query which will be used to find our Backup object. - backupQuery := types.NamespacedName{ - Name: instance.ObjectMeta.Name, - Namespace: instance.ObjectMeta.Namespace, - } - rd := &Reconciler{ - Client: fake.NewClientBuilder().WithObjects(instance).Build(), - Scheme: scheme.Scheme, - Recorder: mockevents.New(), - Params: Params{ - PodSpec: restic.PodSpecParams{ - CPU: "500m", - Memory: "512Mi", - ResticImage: "docker.io/restic/restic:0.9.5", - MySQLImage: "skpr/mtk-mysql", - WorkingDir: "/home/shepherd", - Tags: []string{}, - }, - }, - } - - _, err = rd.Reconcile(context.TODO(), reconcile.Request{ - NamespacedName: backupQuery, - }) - assert.Nil(t, err) - - // Query which will be used to find our finalizer job object. - jobName := fmt.Sprintf("restic-delete-%s", backupQuery.Name) - jobQuery := types.NamespacedName{ - Name: jobName, - Namespace: backupQuery.Namespace, - } - found := &batchv1.Job{} - err = rd.Client.Get(context.TODO(), jobQuery, found) - assert.Nil(t, err) - assert.Equal(t, jobName, found.Name, "restic delete job found") + assert.Equal(t, 1, len(list.Items)) } diff --git a/controllers/extension/backupscheduled/controller.go b/controllers/extension/backupscheduled/controller.go index 39e44dd0..abb1db76 100644 --- a/controllers/extension/backupscheduled/controller.go +++ b/controllers/extension/backupscheduled/controller.go @@ -40,7 +40,6 @@ import ( shpmetav1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" "github.com/universityofadelaide/shepherd-operator/internal/clock" "github.com/universityofadelaide/shepherd-operator/internal/events" - "github.com/universityofadelaide/shepherd-operator/internal/restic" scheduledutils "github.com/universityofadelaide/shepherd-operator/internal/scheduled" ) @@ -348,9 +347,6 @@ func buildBackup(scheduled *extensionv1.BackupScheduled, scheme *runtime.Scheme, Name: fmt.Sprintf("%s-%d", scheduled.Name, scheduledTime.Unix()), Namespace: scheduled.ObjectMeta.Namespace, Labels: scheduled.Labels, - Annotations: map[string]string{ - restic.FriendlyNameAnnotation: scheduledTime.Format(shpmetav1.FriendlyNameFormat), - }, }, Spec: extensionv1.BackupSpec{ MySQL: scheduled.Spec.MySQL, diff --git a/controllers/extension/restore/controller.go b/controllers/extension/restore/controller.go index 90d4bf62..f21e9242 100644 --- a/controllers/extension/restore/controller.go +++ b/controllers/extension/restore/controller.go @@ -19,10 +19,13 @@ package restore import ( "context" "fmt" + "time" + "github.com/go-logr/logr" "github.com/go-test/deep" + osv1 "github.com/openshift/api/apps/v1" osv1client "github.com/openshift/client-go/apps/clientset/versioned/typed/apps/v1" - batchv1 "k8s.io/api/batch/v1" + "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -38,9 +41,39 @@ import ( extensionv1 "github.com/universityofadelaide/shepherd-operator/apis/extension/v1" shpdmetav1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" + awscli "github.com/universityofadelaide/shepherd-operator/internal/aws/cli" "github.com/universityofadelaide/shepherd-operator/internal/events" - "github.com/universityofadelaide/shepherd-operator/internal/restic" - resticutils "github.com/universityofadelaide/shepherd-operator/internal/restic" + "github.com/universityofadelaide/shepherd-operator/internal/helper" + podutils "github.com/universityofadelaide/shepherd-operator/internal/k8s/pod" +) + +const ( + // ControllerName is used to identify this controller in logs and events. + ControllerName = "restore-controller" + + // EnvAWSAccessKeyID for authentication. + EnvAWSAccessKeyID = "AWS_ACCESS_KEY_ID" + // EnvAWSSecretAccessKey for authentication. + EnvAWSSecretAccessKey = "AWS_SECRET_ACCESS_KEY" + // EnvAWSRegion for authentication. + EnvAWSRegion = "AWS_DEFAULT_REGION" + + // EnvMySQLHostname for MySQL connection. + EnvMySQLHostname = "MYSQL_HOSTNAME" + // EnvMySQLDatabase for MySQL connection. + EnvMySQLDatabase = "MYSQL_DATABASE" + // EnvMySQLPort for MySQL connection. + EnvMySQLPort = "MYSQL_PORT" + // EnvMySQLUsername for MySQL connection. + EnvMySQLUsername = "MYSQL_USERNAME" + // EnvMySQLPassword for MySQL connection. + EnvMySQLPassword = "MYSQL_PASSWORD" + + // VolumeMySQL identifier for mysql storage. + VolumeMySQL = "mysql" + + // WebDirectory is working directory for the restore deployment step. + WebDirectory = "/code" ) // Reconciler reconciles a Restore object @@ -53,10 +86,29 @@ type Reconciler struct { Params Params } -// Params which are provided to this controller. +// Params used by this controller. type Params struct { - // Parameters which are used when provisioning a Pod instance. - PodSpec restic.PodSpecParams + ResourceRequirements corev1.ResourceRequirements + WorkingDir string + // MySQL params used by this controller. + MySQL MySQL + // AWS params used by this controller. + AWS AWS +} + +// MySQL params used by this controller. +type MySQL struct { + Image string +} + +// AWS params used by this controller. +type AWS struct { + Endpoint string + BucketName string + Image string + FieldKeyID string + FieldAccessKey string + Region string } //+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete @@ -101,11 +153,12 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu case shpdmetav1.PhaseNew: // Requeue the operation for 30 seconds if the backup is new. logger.Info(fmt.Sprintf("Requeueing restore %s because the backup %s is New", restore.ObjectMeta.Name, backup.ObjectMeta.Name)) - return resticutils.RequeueAfterSeconds(30), nil + return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 30}, nil case shpdmetav1.PhaseInProgress: // Requeue the operation for 15 seconds if the backup is still in progress. logger.Info(fmt.Sprintf("Requeueing restore %s because the backup %s is In Progress", restore.ObjectMeta.Name, backup.ObjectMeta.Name)) - return resticutils.RequeueAfterSeconds(15), nil + return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 15}, nil + } // Catch-all for any other non Completed phases. @@ -133,89 +186,384 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return reconcile.Result{}, nil } - spec, err := resticutils.PodSpecRestore(restore, dc, backup.Status.ResticID, r.Params.PodSpec, restore.ObjectMeta.GetLabels()["site"]) + secret, err := r.createSecret(ctx, backup, r.Params.AWS.FieldKeyID, r.Params.AWS.FieldAccessKey) if err != nil { - return reconcile.Result{}, err + return ctrl.Result{}, fmt.Errorf("failed to create Secret: %w", err) } - var ( - parallelism int32 = 1 - completions int32 = 1 - activeDeadline int64 = 3600 - backOffLimit int32 = 2 - ) + status, err := r.createPod(ctx, restore, secret, dc) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create Pod: %w", err) + } - job := &batchv1.Job{ + err = r.updateStatus(ctx, logger, restore, status) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update Backup status: %w", err) + } + + logger.Info("Reconcile finished") + + return reconcile.Result{}, nil +} + +// Creates Secret object based on the provided Spec configuration. +func (r *Reconciler) createSecret(ctx context.Context, backup *extensionv1.Backup, key, access string) (*corev1.Secret, error) { + secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%s", resticutils.Prefix, restore.ObjectMeta.Name), - Namespace: restore.ObjectMeta.Namespace, + Name: fmt.Sprintf("restore-%s", backup.ObjectMeta.Name), + Namespace: backup.ObjectMeta.Namespace, }, - Spec: batchv1.JobSpec{ - Parallelism: ¶llelism, - Completions: &completions, - ActiveDeadlineSeconds: &activeDeadline, - BackoffLimit: &backOffLimit, - Template: corev1.PodTemplateSpec{ - Spec: spec, - }, + Data: map[string][]byte{ + EnvAWSAccessKeyID: []byte(key), + EnvAWSSecretAccessKey: []byte(access), }, } - logger.Info("Creating Job") + if err := controllerutil.SetControllerReference(backup, secret, r.Scheme); err != nil { + return nil, err + } - if err := controllerutil.SetControllerReference(backup, job, r.Scheme); err != nil { - return reconcile.Result{}, err + if err := r.Create(ctx, secret); client.IgnoreNotFound(err) != nil { + return nil, err } - if err := r.Create(ctx, job); client.IgnoreNotFound(err) != nil { - return reconcile.Result{}, err + return secret, nil +} + +// Creates Pod objects based on the provided Spec configuration. +func (r *Reconciler) createPod(ctx context.Context, restore *extensionv1.Restore, secret *corev1.Secret, dc *osv1.DeploymentConfig) (extensionv1.RestoreStatus, error) { + var initContainers []corev1.Container + var containers []corev1.Container + + // InitContainer which restores db to emptydir volume. + for mysqlName, mysqlStatus := range restore.Spec.MySQL { + cmd := awscli.CommandParams{ + Endpoint: r.Params.AWS.Endpoint, + Service: "s3", + Operation: "cp", + Args: []string{ + fmt.Sprintf("s3://%s/%s/%s/mysql/%s.sql", r.Params.AWS.BucketName, restore.ObjectMeta.Namespace, restore.Spec.BackupName, mysqlName), + fmt.Sprintf("mysql/%s.sql", mysqlName), + }, + } + + initContainers = append(initContainers, corev1.Container{ + Name: fmt.Sprintf("restore-%s", mysqlName), + Image: r.Params.AWS.Image, + Resources: r.Params.ResourceRequirements, + WorkingDir: r.Params.WorkingDir, + Command: []string{ + "/bin/sh", "-c", + }, + Args: awscli.Command(cmd), + Env: []corev1.EnvVar{ + { + Name: EnvAWSAccessKeyID, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secret.ObjectMeta.Name, + }, + Key: EnvAWSAccessKeyID, + }, + }, + }, + { + Name: EnvAWSSecretAccessKey, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secret.ObjectMeta.Name, + }, + Key: EnvAWSSecretAccessKey, + }, + }, + }, + { + Name: EnvAWSRegion, + Value: r.Params.AWS.Region, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: VolumeMySQL, + MountPath: fmt.Sprintf("%s/mysql", r.Params.WorkingDir), + }, + }, + }) + + initContainers = append(initContainers, corev1.Container{ + Name: fmt.Sprintf("import-%s", mysqlName), + Image: r.Params.MySQL.Image, + Resources: r.Params.ResourceRequirements, + WorkingDir: r.Params.WorkingDir, + Command: []string{ + "database-restore", + }, + Args: []string{ + fmt.Sprintf("mysql/%s.sql", mysqlName), + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: VolumeMySQL, + MountPath: fmt.Sprintf("%s/mysql", r.Params.WorkingDir), + }, + }, + Env: []corev1.EnvVar{ + { + Name: EnvMySQLHostname, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Hostname, + }, + }, + }, + { + Name: EnvMySQLDatabase, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Database, + }, + }, + }, + { + Name: EnvMySQLPort, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Port, + }, + }, + }, + { + Name: EnvMySQLUsername, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Username, + }, + }, + }, + { + Name: EnvMySQLPassword, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: mysqlStatus.Secret.Name, + }, + Key: mysqlStatus.Secret.Keys.Password, + }, + }, + }, + }, + }) } - if err = r.Get(ctx, types.NamespacedName{ - Namespace: job.ObjectMeta.Namespace, - Name: job.ObjectMeta.Name, - }, job); err != nil { - return ctrl.Result{}, err + // Volume definitions for the pod. + specVolumes := []corev1.Volume{ + { + Name: VolumeMySQL, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, } - logger.Info("Syncing status") + // Attach restore volumes to pod. + for volumeName, volumeSpec := range restore.Spec.Volumes { + cmd := awscli.CommandParams{ + Endpoint: r.Params.AWS.Endpoint, + Service: "s3", + Operation: "cp", + Args: []string{ + fmt.Sprintf("s3://%s/%s/%s/%s/", r.Params.AWS.BucketName, restore.ObjectMeta.Namespace, restore.Spec.BackupName, volumeName), + fmt.Sprintf("%s/volume/%s/", r.Params.WorkingDir, volumeName), + }, + } + + specVolumes = append(specVolumes, corev1.Volume{ + Name: fmt.Sprintf("volume-%s", volumeName), + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: volumeSpec.ClaimName, + }, + }, + }) + + // Container which restores volumes. + initContainers = append(initContainers, corev1.Container{ + Name: "restore-volumes", + Image: r.Params.AWS.Image, + Resources: r.Params.ResourceRequirements, + WorkingDir: r.Params.WorkingDir, + Command: []string{ + "/bin/sh", "-c", + }, + Args: awscli.Command(cmd), + Env: []corev1.EnvVar{ + { + Name: EnvAWSAccessKeyID, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secret.ObjectMeta.Name, + }, + Key: EnvAWSAccessKeyID, + }, + }, + }, + { + Name: EnvAWSSecretAccessKey, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secret.ObjectMeta.Name, + }, + Key: EnvAWSSecretAccessKey, + }, + }, + }, + { + Name: EnvAWSRegion, + Value: r.Params.AWS.Region, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: fmt.Sprintf("volume-%s", volumeName), + MountPath: fmt.Sprintf("%s/volume/%s", r.Params.WorkingDir, volumeName), + ReadOnly: false, + }, + }, + }) + } + + var status extensionv1.RestoreStatus - status := extensionv1.RestoreStatus{ - Phase: shpdmetav1.PhaseNew, - StartTime: job.Status.StartTime, - CompletionTime: job.Status.CompletionTime, + dcContainer, err := getWebContainerFromDc(dc) + if err != nil { + return status, err } - if job.Status.Active > 0 { - status.Phase = shpdmetav1.PhaseInProgress - } else { - if job.Status.Succeeded > 0 { - status.Phase = shpdmetav1.PhaseCompleted - } else if job.Status.Failed > 0 { - status.Phase = shpdmetav1.PhaseFailed + dcVolumeMounts := dcContainer.VolumeMounts + + // Add volumes from the deploymentconfig that we don't already have in the restore spec. + for _, dcVolume := range dc.Spec.Template.Spec.Volumes { + found := false + for _, specVolume := range specVolumes { + if dcVolume.PersistentVolumeClaim != nil && specVolume.PersistentVolumeClaim != nil && + dcVolume.PersistentVolumeClaim.ClaimName == specVolume.PersistentVolumeClaim.ClaimName { + found = true + // We've found a volume we already have, make sure the volume mount name references the existing volume. + for i, dcVolumeMount := range dcVolumeMounts { + if dcVolumeMount.Name == dcVolume.Name { + dcVolumeMounts[i].Name = specVolume.Name + } + } + } + } + + if !found { + specVolumes = append(specVolumes, dcVolume) } } - if diff := deep.Equal(restore.Status, status); diff != nil { - logger.Info(fmt.Sprintf("Status change dectected: %s", diff)) + // Container which runs deployment steps. + // @todo, Try and make this into a reusable CRD. + containers = append(containers, corev1.Container{ + Name: "restore-deploy", + Image: dcContainer.Image, + Resources: r.Params.ResourceRequirements, + WorkingDir: WebDirectory, + Command: []string{ + "/bin/sh", "-c", + }, + Args: []string{ + helper.TprintfMustParse( + "drush -r {{.WebDir}}/web cr && drush -r {{.WebDir}}/web -y updb && robo config:import-plus && drush -r {{.WebDir}}/web cr", + map[string]interface{}{ + "WebDir": WebDirectory, + }, + ), + }, + Env: dcContainer.Env, + VolumeMounts: dcVolumeMounts, + }) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("restore-%s", restore.ObjectMeta.Name), + Namespace: restore.ObjectMeta.Namespace, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + InitContainers: initContainers, + Containers: containers, + Volumes: specVolumes, + }, + } + + if err := controllerutil.SetControllerReference(restore, pod, r.Scheme); err != nil { + return status, err + } - restore.Status = status + if err := r.Create(ctx, pod); client.IgnoreNotFound(err) != nil { + return status, err + } - err := r.Status().Update(context.TODO(), restore) - if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to update status: %w", err) - } + if err := r.Get(ctx, types.NamespacedName{ + Namespace: pod.ObjectMeta.Namespace, + Name: pod.ObjectMeta.Name, + }, pod); err != nil { + return status, err } - logger.Info("Reconcile finished") + status.Phase = podutils.GetPhase(pod.Status) + status.StartTime = pod.Status.StartTime + status.CompletionTime = podutils.CompletionTime(pod) - return reconcile.Result{}, nil + return status, nil +} + +// Update the Backup status. +func (r *Reconciler) updateStatus(ctx context.Context, log logr.Logger, restore *extensionv1.Restore, status extensionv1.RestoreStatus) error { + diff := deep.Equal(restore.Status, status) + if diff == nil { + return nil + } + + log.Info(fmt.Sprintf("Status change dectected: %s", diff)) + + restore.Status = status + + return r.Status().Update(ctx, restore) +} + +// getWebContainerFromDc loops through a deploymentconfig to find the container with the same name. This is considered +// the web container in shepherd. +func getWebContainerFromDc(dc *osv1.DeploymentConfig) (corev1.Container, error) { + for _, container := range dc.Spec.Template.Spec.Containers { + if container.Name == dc.ObjectMeta.Name { + return container, nil + } + } + return corev1.Container{}, errors.Errorf("web container not found for dc %s", dc.ObjectMeta.Name) } // SetupWithManager sets up the controller with the Manager. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&extensionv1.Restore{}). - Owns(&batchv1.Job{}). + Owns(&corev1.Pod{}). Complete(r) } diff --git a/controllers/extension/restore/controller_test.go b/controllers/extension/restore/controller_test.go index 59a15c77..7f1d3a56 100644 --- a/controllers/extension/restore/controller_test.go +++ b/controllers/extension/restore/controller_test.go @@ -15,7 +15,6 @@ import ( extensionv1 "github.com/universityofadelaide/shepherd-operator/apis/extension/v1" mockevents "github.com/universityofadelaide/shepherd-operator/internal/events/mock" - "github.com/universityofadelaide/shepherd-operator/internal/restic" ) func TestReconcile(t *testing.T) { @@ -44,13 +43,17 @@ func TestReconcile(t *testing.T) { Scheme: scheme.Scheme, Recorder: mockevents.New(), Params: Params{ - PodSpec: restic.PodSpecParams{ - CPU: "500m", - Memory: "512Mi", - ResticImage: "docker.io/restic/restic:0.9.5", - MySQLImage: "skpr/mtk-mysql", - WorkingDir: "/home/shepherd", - Tags: []string{}, + ResourceRequirements: corev1.ResourceRequirements{}, + WorkingDir: "/tmp", + MySQL: MySQL{ + Image: "mysql:latest", + }, + AWS: AWS{ + BucketName: "test", + Image: "aws-cli:latest", + FieldKeyID: "aws.key.id", + FieldAccessKey: "aws.access.key", + Region: "ap-southeast-2", }, }, } diff --git a/controllers/extension/sync/backup/controller.go b/controllers/extension/sync/backup/controller.go index c290456c..d7063d9e 100644 --- a/controllers/extension/sync/backup/controller.go +++ b/controllers/extension/sync/backup/controller.go @@ -19,8 +19,6 @@ package backup import ( "context" "fmt" - "time" - "github.com/go-test/deep" "github.com/pkg/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -35,7 +33,6 @@ import ( extensionv1 "github.com/universityofadelaide/shepherd-operator/apis/extension/v1" shpdmetav1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" - resticutils "github.com/universityofadelaide/shepherd-operator/internal/restic" ) const ( @@ -76,13 +73,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("sync-%s-backup", sync.ObjectMeta.Name), Namespace: sync.ObjectMeta.Namespace, - Annotations: map[string]string{ - resticutils.FriendlyNameAnnotation: time.Now().Format(shpdmetav1.FriendlyNameFormat), - }, Labels: map[string]string{ - "site": sync.Spec.Site, - "environment": sync.Spec.BackupEnv, - resticutils.SyncLabel: "1", + "site": sync.Spec.Site, + "environment": sync.Spec.BackupEnv, }, }, Spec: sync.Spec.BackupSpec, diff --git a/controllers/extension/sync/restore/controller.go b/controllers/extension/sync/restore/controller.go index 15a92017..daa0cefb 100644 --- a/controllers/extension/sync/restore/controller.go +++ b/controllers/extension/sync/restore/controller.go @@ -19,6 +19,7 @@ package restore import ( "context" "fmt" + "time" "github.com/go-test/deep" osv1 "github.com/openshift/api/apps/v1" @@ -36,7 +37,6 @@ import ( extensionv1 "github.com/universityofadelaide/shepherd-operator/apis/extension/v1" shpdmetav1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" - resticutils "github.com/universityofadelaide/shepherd-operator/internal/restic" ) const ( @@ -83,7 +83,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu // Check the deployment is running so we can create a restore. if !isDeploymentRunning(dc) { logger.Info("Deployment not yet running, will requeue after 10 seconds") - return resticutils.RequeueAfterSeconds(60), nil + return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 60}, nil } restore := &extensionv1.Restore{ diff --git a/internal/aws/cli/command.go b/internal/aws/cli/command.go new file mode 100644 index 00000000..fa4311f7 --- /dev/null +++ b/internal/aws/cli/command.go @@ -0,0 +1,26 @@ +package cli + +// CommandParams configures an AWS CLI command. +type CommandParams struct { + // Endpoint which is used to override the AWS services endpoint eg. For local development. + Endpoint string + // Service which the command will interact with eg. S3. + Service string + // Operation which will be performed with the Service. + Operation string + // Args used as part of an Operation. + Args []string +} + +// Command which is compatible with the AWS CLI. +func Command(params CommandParams) []string { + command := []string{params.Service} + + if params.Endpoint != "" { + command = append(command, "--endpoint-url", params.Endpoint) + } + + command = append(command, params.Operation) + + return append(command, params.Args...) +} diff --git a/internal/aws/cli/command_test.go b/internal/aws/cli/command_test.go new file mode 100644 index 00000000..c1e9d5a0 --- /dev/null +++ b/internal/aws/cli/command_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommand(t *testing.T) { + params := CommandParams{ + Service: "s3", + Operation: "cp", + Args: []string{ + "foo.txt", + "s3://bar/foo.txt", + }, + } + + want := []string{ + "s3", + "cp", + "foo.txt", + "s3://bar/foo.txt", + } + + assert.Equal(t, want, Command(params)) +} + +func TestCommandWithEndpoint(t *testing.T) { + params := CommandParams{ + Endpoint: "http://localhost:9000", + Service: "s3", + Operation: "cp", + Args: []string{ + "foo.txt", + "s3://bar/foo.txt", + }, + } + + want := []string{ + "s3", + "--endpoint-url", "http://localhost:9000", + "cp", + "foo.txt", + "s3://bar/foo.txt", + } + + assert.Equal(t, want, Command(params)) +} diff --git a/internal/k8s/pod/pod.go b/internal/k8s/pod/pod.go new file mode 100644 index 00000000..2338b5e5 --- /dev/null +++ b/internal/k8s/pod/pod.go @@ -0,0 +1,39 @@ +package pod + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + shpdmetav1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" +) + +// CompletionTime for a Pod using container status. +func CompletionTime(pod *corev1.Pod) *metav1.Time { + oldest := pod.ObjectMeta.CreationTimestamp + + for _, container := range pod.Status.ContainerStatuses { + if container.State.Terminated == nil { + continue + } + + if container.State.Terminated.FinishedAt.Before(&oldest) { + continue + } + + oldest = container.State.Terminated.FinishedAt + } + + return &oldest +} + +// GetPhase from the Pod status object. +func GetPhase(status corev1.PodStatus) shpdmetav1.Phase { + switch status.Phase { + case corev1.PodSucceeded: + return shpdmetav1.PhaseCompleted + case corev1.PodFailed: + return shpdmetav1.PhaseFailed + default: + return shpdmetav1.PhaseInProgress + } +} diff --git a/internal/restic/const.go b/internal/restic/const.go deleted file mode 100644 index 3584c0e3..00000000 --- a/internal/restic/const.go +++ /dev/null @@ -1,25 +0,0 @@ -package restic - -// Prefix for discovering Restic resources. -const Prefix = "restic" - -// VolumeSecrets identifier used for Restic secret. -const VolumeSecrets = "restic-secrets" - -// VolumeRepository identifier used for Restic repository. -const VolumeRepository = "restic-repository" - -// ResticSecretPasswordName is the name of the secret the restic password is stored in. -const ResticSecretPasswordName = "shepherd-restic-secret" - -// ResticBackupContainerName is the name of the container in the restic backup pod. -const ResticBackupContainerName = "restic-backup" - -// WebDirectory is working directory for the restore deployment step. -const WebDirectory = "/code" - -// FriendlyNameAnnotation is the name of the annotation which stores the friendly name of a backup to display in the Shepherd UI. -const FriendlyNameAnnotation = "backups.shepherd/friendly-name" - -// SyncLabel is the name of the label to determine that a backup is part of a sync. -const SyncLabel = "is-sync" diff --git a/internal/restic/container.go b/internal/restic/container.go deleted file mode 100644 index 1087546b..00000000 --- a/internal/restic/container.go +++ /dev/null @@ -1,52 +0,0 @@ -package restic - -import ( - "fmt" - - corev1 "k8s.io/api/core/v1" -) - -const ( - // EnvResticRepository for Restic configuration. - EnvResticRepository = "RESTIC_REPOSITORY" - // EnvResticPasswordFile for Restic configuration. - EnvResticPasswordFile = "RESTIC_PASSWORD_FILE" - - // ResticPassword identifier for loading the restic password. - ResticPassword = "password" - - // SecretDir defines the directory where secrets are mounted. - SecretDir = "/etc/restic" - - // ResticRepoDir defines the directory to mount the restic repository to. - ResticRepoDir = "/srv/backups" -) - -// WrapContainer with the information required to interact with Restic. -func WrapContainer(container corev1.Container, siteId, namespace string) corev1.Container { - envs := []corev1.EnvVar{ - { - Name: EnvResticRepository, - Value: fmt.Sprintf("%s/%s/%s", ResticRepoDir, namespace, siteId), - }, - { - Name: EnvResticPasswordFile, - Value: fmt.Sprintf("%s/%s", SecretDir, ResticPassword), - }, - } - - container.Env = append(container.Env, envs...) - - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: VolumeSecrets, - MountPath: SecretDir, - ReadOnly: true, - }) - - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: VolumeRepository, - MountPath: ResticRepoDir, - }) - - return container -} diff --git a/internal/restic/pod.go b/internal/restic/pod.go deleted file mode 100644 index 41c673b9..00000000 --- a/internal/restic/pod.go +++ /dev/null @@ -1,498 +0,0 @@ -package restic - -import ( - "fmt" - "github.com/universityofadelaide/shepherd-operator/internal/helper" - "strings" - - osv1 "github.com/openshift/api/apps/v1" - "github.com/pkg/errors" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - - extensionv1 "github.com/universityofadelaide/shepherd-operator/apis/extension/v1" - v1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" -) - -const ( - // EnvMySQLHostname for MySQL connection. - EnvMySQLHostname = "MYSQL_HOSTNAME" - // EnvMySQLDatabase for MySQL connection. - EnvMySQLDatabase = "MYSQL_DATABASE" - // EnvMySQLPort for MySQL connection. - EnvMySQLPort = "MYSQL_PORT" - // EnvMySQLUsername for MySQL connection. - EnvMySQLUsername = "MYSQL_USERNAME" - // EnvMySQLPassword for MySQL connection. - EnvMySQLPassword = "MYSQL_PASSWORD" - - // VolumeMySQL identifier for mysql storage. - VolumeMySQL = "restic-mysql" -) - -// PodSpecParams which are passed into the PodSpecBackup function. -type PodSpecParams struct { - CPU string - Memory string - ResticImage string - MySQLImage string - WorkingDir string - Tags []string -} - -// PodSpecBackup defines how a backup can be executed using a Pod. -func PodSpecBackup(backup *extensionv1.Backup, params PodSpecParams, siteId string) (corev1.PodSpec, error) { - cpu, err := resource.ParseQuantity(params.CPU) - if err != nil { - return corev1.PodSpec{}, err - } - - memory, err := resource.ParseQuantity(params.Memory) - if err != nil { - return corev1.PodSpec{}, err - } - - resources := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - } - - resticInit := WrapContainer(corev1.Container{ - Name: "restic-init", - Image: params.ResticImage, - Resources: resources, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - // Init will return an exit code of 1 if the repository already exists. - // If this failed for a non "already exists" error then we will see it - // in the main containers "restic backup" execution. - "restic init || true", - }, - }, siteId, backup.ObjectMeta.Namespace) - - resticBackup := corev1.Container{ - Name: ResticBackupContainerName, - Image: params.ResticImage, - Resources: resources, - WorkingDir: params.WorkingDir, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - // Backup, excluding any cached twig, css, or js. - fmt.Sprintf("restic --verbose %s backup . --exclude volume/*/*/php --exclude volume/*/*/css --exclude volume/*/*/js", formatTags(params.Tags)), - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: fmt.Sprintf("%s/mysql", params.WorkingDir), - }, - }, - } - - for volumeName := range backup.Spec.Volumes { - resticBackup.VolumeMounts = append(resticBackup.VolumeMounts, corev1.VolumeMount{ - Name: fmt.Sprintf("volume-%s", volumeName), - MountPath: fmt.Sprintf("%s/volume/%s", params.WorkingDir, volumeName), - ReadOnly: true, - }) - } - - resticBackup = WrapContainer(resticBackup, siteId, backup.ObjectMeta.Namespace) - - spec := corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - InitContainers: []corev1.Container{ - resticInit, - }, - Containers: []corev1.Container{ - resticBackup, - }, - Volumes: AttachVolume([]corev1.Volume{ - { - Name: VolumeMySQL, - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{ - Medium: corev1.StorageMediumDefault, - }, - }, - }, - { - Name: VolumeRepository, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: VolumeRepository, - }, - }, - }, - }), - } - - for volumeName, volumeSpec := range backup.Spec.Volumes { - spec.Volumes = append(spec.Volumes, corev1.Volume{ - Name: fmt.Sprintf("volume-%s", volumeName), - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: volumeSpec.ClaimName, - }, - }, - }) - } - - for mysqlName, mysqlStatus := range backup.Spec.MySQL { - spec.InitContainers = append(spec.InitContainers, corev1.Container{ - Name: fmt.Sprintf("mysql-%s", mysqlName), - Image: params.MySQLImage, - Resources: resources, - Env: mysqlEnvVars(mysqlStatus), - WorkingDir: params.WorkingDir, - Command: []string{ - "bash", - "-c", - }, - Args: []string{ - fmt.Sprintf("database-backup > mysql/%s.sql", mysqlName), - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: fmt.Sprintf("%s/mysql", params.WorkingDir), - }, - }, - }) - } - - return spec, nil -} - -// PodSpecRestore defines how a restore can be executed using a Pod. -func PodSpecRestore(restore *extensionv1.Restore, dc *osv1.DeploymentConfig, resticId string, params PodSpecParams, siteId string) (corev1.PodSpec, error) { - cpu, err := resource.ParseQuantity(params.CPU) - if err != nil { - return corev1.PodSpec{}, err - } - - memory, err := resource.ParseQuantity(params.Memory) - if err != nil { - return corev1.PodSpec{}, err - } - - resources := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - } - - var initContainers []corev1.Container - var containers []corev1.Container - - // InitContainer which restores db to emptydir volume. - for mysqlName, mysqlStatus := range restore.Spec.MySQL { - initContainers = append(initContainers, corev1.Container{ - Name: fmt.Sprintf("restic-restore-%s", mysqlName), - Image: params.ResticImage, - Resources: resources, - WorkingDir: params.WorkingDir, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - helper.TprintfMustParse( - "restic dump --quiet {{.ResticId}} /{{.SQLPath}} > ./{{.SQLPath}}", - map[string]interface{}{ - "ResticId": resticId, - "SQLPath": fmt.Sprintf("mysql/%s.sql", mysqlName), - }, - ), - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: fmt.Sprintf("%s/mysql", params.WorkingDir), - }, - }, - }) - - initContainers = append(initContainers, corev1.Container{ - Name: fmt.Sprintf("restic-import-%s", mysqlName), - Image: params.MySQLImage, - Resources: resources, - WorkingDir: params.WorkingDir, - Command: []string{ - "database-restore", - }, - Args: []string{ - fmt.Sprintf("mysql/%s.sql", mysqlName), - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: fmt.Sprintf("%s/mysql", params.WorkingDir), - }, - }, - Env: mysqlEnvVars(mysqlStatus), - }) - } - - // Mount restore volumes into volume restore container. - var resticRestoreVolumeMounts []corev1.VolumeMount - var resticVolumeIncludeArgs []string - for volumeName := range restore.Spec.Volumes { - resticVolumeIncludeArgs = append(resticVolumeIncludeArgs, fmt.Sprintf("--include /volume/%s", volumeName)) - resticRestoreVolumeMounts = append(resticRestoreVolumeMounts, corev1.VolumeMount{ - Name: fmt.Sprintf("volume-%s", volumeName), - MountPath: fmt.Sprintf("%s/volume/%s", params.WorkingDir, volumeName), - ReadOnly: false, - }) - } - - // Container which restores volumes. - initContainers = append(initContainers, corev1.Container{ - Name: "restic-restore-volumes", - Image: params.ResticImage, - Resources: resources, - WorkingDir: params.WorkingDir, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - helper.TprintfMustParse( - "restic restore {{.ResticId}} --target . {{.IncludeArgs}}", - map[string]interface{}{ - "ResticId": resticId, - // @todo might be able to iterate through an array of volumeNames in the template. - "IncludeArgs": strings.Join(resticVolumeIncludeArgs, " "), - }, - ), - }, - VolumeMounts: resticRestoreVolumeMounts, - }) - - // Volume definitions for the pod. - specVolumes := AttachVolume([]corev1.Volume{ - { - Name: VolumeMySQL, - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }, - { - Name: VolumeRepository, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: VolumeRepository, - }, - }, - }, - }) - // Attach restore volumes to pod. - for volumeName, volumeSpec := range restore.Spec.Volumes { - specVolumes = append(specVolumes, corev1.Volume{ - Name: fmt.Sprintf("volume-%s", volumeName), - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: volumeSpec.ClaimName, - }, - }, - }) - } - - dcContainer, err := getWebContainerFromDc(dc) - if err != nil { - return corev1.PodSpec{}, err - } - dcVolumeMounts := dcContainer.VolumeMounts - // Add volumes from the deploymentconfig that we don't already have in the restore spec. - for _, dcVolume := range dc.Spec.Template.Spec.Volumes { - found := false - for _, specVolume := range specVolumes { - if dcVolume.PersistentVolumeClaim != nil && specVolume.PersistentVolumeClaim != nil && - dcVolume.PersistentVolumeClaim.ClaimName == specVolume.PersistentVolumeClaim.ClaimName { - found = true - // We've found a volume we already have, make sure the volume mount name references the existing volume. - for i, dcVolumeMount := range dcVolumeMounts { - if dcVolumeMount.Name == dcVolume.Name { - dcVolumeMounts[i].Name = specVolume.Name - } - } - } - } - - if !found { - specVolumes = append(specVolumes, dcVolume) - } - } - // Container which runs deployment steps. - containers = append(containers, corev1.Container{ - Name: "restore-deploy", - Image: dcContainer.Image, - Resources: resources, - WorkingDir: WebDirectory, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - helper.TprintfMustParse( - "drush -r {{.WebDir}}/web cr && drush -r {{.WebDir}}/web -y updb && robo config:import-plus && drush -r {{.WebDir}}/web cr", - map[string]interface{}{ - "WebDir": WebDirectory, - }, - ), - }, - Env: dcContainer.Env, - VolumeMounts: dcVolumeMounts, - }) - - for i := range initContainers { - initContainers[i] = WrapContainer(initContainers[i], siteId, restore.ObjectMeta.Namespace) - } - - spec := corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - InitContainers: initContainers, - Containers: containers, - Volumes: specVolumes, - } - - return spec, nil -} - -// PodSpecDelete defines how a snapshot can be forgotten using a Pod. -func PodSpecDelete(resticId, namespace, site string, params PodSpecParams) (corev1.PodSpec, error) { - cpu, err := resource.ParseQuantity(params.CPU) - if err != nil { - return corev1.PodSpec{}, err - } - - memory, err := resource.ParseQuantity(params.Memory) - if err != nil { - return corev1.PodSpec{}, err - } - - resources := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - } - - var containers []corev1.Container - containers = append(containers, WrapContainer(corev1.Container{ - Name: fmt.Sprintf("restic-delete-%s", resticId), - Image: params.ResticImage, - Resources: resources, - WorkingDir: ResticRepoDir, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - fmt.Sprintf("restic forget --prune %s", resticId), - }, - }, site, namespace)) - - spec := corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - Containers: containers, - Volumes: AttachVolume([]corev1.Volume{ - { - Name: VolumeRepository, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: VolumeRepository, - }, - }, - }, - }), - } - - return spec, nil -} - -// mysqlEnvVars returns a list of environment variables for a container based on the mysql spec. -func mysqlEnvVars(mysqlStatus v1.SpecMySQL) []corev1.EnvVar { - return []corev1.EnvVar{ - { - Name: EnvMySQLHostname, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: mysqlStatus.Secret.Name, - }, - Key: mysqlStatus.Secret.Keys.Hostname, - }, - }, - }, - { - Name: EnvMySQLDatabase, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: mysqlStatus.Secret.Name, - }, - Key: mysqlStatus.Secret.Keys.Database, - }, - }, - }, - { - Name: EnvMySQLPort, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: mysqlStatus.Secret.Name, - }, - Key: mysqlStatus.Secret.Keys.Port, - }, - }, - }, - { - Name: EnvMySQLUsername, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: mysqlStatus.Secret.Name, - }, - Key: mysqlStatus.Secret.Keys.Username, - }, - }, - }, - { - Name: EnvMySQLPassword, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: mysqlStatus.Secret.Name, - }, - Key: mysqlStatus.Secret.Keys.Password, - }, - }, - }, - } -} - -// getWebContainerFromDc loops through a deploymentconfig to find the container with the same name. This is considered -// the web container in shepherd. -func getWebContainerFromDc(dc *osv1.DeploymentConfig) (corev1.Container, error) { - for _, container := range dc.Spec.Template.Spec.Containers { - if container.Name == dc.ObjectMeta.Name { - return container, nil - } - } - return corev1.Container{}, errors.Errorf("web container not found for dc %s", dc.ObjectMeta.Name) -} diff --git a/internal/restic/pod_test.go b/internal/restic/pod_test.go deleted file mode 100644 index 59bb31eb..00000000 --- a/internal/restic/pod_test.go +++ /dev/null @@ -1,623 +0,0 @@ -//go:build unit -// +build unit - -package restic - -import ( - "testing" - - osv1 "github.com/openshift/api/apps/v1" - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - extensionv1 "github.com/universityofadelaide/shepherd-operator/apis/extension/v1" - shpmetav1 "github.com/universityofadelaide/shepherd-operator/apis/meta/v1" -) - -func TestPodSpecBackup(t *testing.T) { - var params = PodSpecParams{ - CPU: "500m", - Memory: "512Mi", - ResticImage: "test/image", - MySQLImage: "test/mysqlimage", - WorkingDir: "/home/test", - Tags: []string{"tag1"}, - } - backup := extensionv1.Backup{ - ObjectMeta: v1.ObjectMeta{ - Name: "test-backup", - Namespace: "test-namespace", - }, - Spec: extensionv1.BackupSpec{ - Volumes: map[string]shpmetav1.SpecVolume{ - "volume1": { - ClaimName: "claim-volume1", - }, - }, - MySQL: map[string]shpmetav1.SpecMySQL{ - "mysql1": { - Secret: shpmetav1.SpecMySQLSecret{ - Name: "secret1", - Keys: shpmetav1.SpecMySQLSecretKeys{ - Username: "mysql-user", - Password: "mysql-pass", - Database: "mysql-db", - Hostname: "mysql-host", - Port: "mysql-port", - }, - }, - }, - }, - }, - } - cpu, _ := resource.ParseQuantity(params.CPU) - memory, _ := resource.ParseQuantity(params.Memory) - mode := corev1.ConfigMapVolumeSourceDefaultMode - resources := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - } - expected := corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - InitContainers: []corev1.Container{ - { - Name: "restic-init", - Image: "test/image", - Resources: resources, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - "restic init || true", - }, - Env: []corev1.EnvVar{ - { - Name: EnvResticRepository, - Value: "/srv/backups/test-namespace/test-site-id", - }, - { - Name: EnvResticPasswordFile, - Value: "/etc/restic/password", - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeSecrets, - MountPath: SecretDir, - ReadOnly: true, - }, - { - Name: VolumeRepository, - MountPath: ResticRepoDir, - }, - }, - }, - { - Name: "mysql-mysql1", - Image: "test/mysqlimage", - Resources: resources, - Env: []corev1.EnvVar{ - { - Name: EnvMySQLHostname, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-host", - }, - }, - }, - { - Name: EnvMySQLDatabase, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-db", - }, - }, - }, - { - Name: EnvMySQLPort, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-port", - }, - }, - }, - { - Name: EnvMySQLUsername, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-user", - }, - }, - }, - { - Name: EnvMySQLPassword, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-pass", - }, - }, - }, - }, - WorkingDir: "/home/test", - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - "database-backup > mysql/mysql1.sql", - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: "/home/test/mysql", - }, - }, - }, - }, - Containers: []corev1.Container{ - { - Name: ResticBackupContainerName, - Image: "test/image", - Resources: resources, - WorkingDir: "/home/test", - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - "restic --verbose --tag=tag1 backup . --exclude volume/*/*/php --exclude volume/*/*/css --exclude volume/*/*/js", - }, - Env: []corev1.EnvVar{ - { - Name: EnvResticRepository, - Value: "/srv/backups/test-namespace/test-site-id", - }, - { - Name: EnvResticPasswordFile, - Value: "/etc/restic/password", - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: "/home/test/mysql", - }, - { - Name: "volume-volume1", - MountPath: "/home/test/volume/volume1", - ReadOnly: true, - }, - { - Name: VolumeSecrets, - MountPath: SecretDir, - ReadOnly: true, - }, - { - Name: VolumeRepository, - MountPath: ResticRepoDir, - }, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: VolumeMySQL, - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{ - Medium: corev1.StorageMediumDefault, - }, - }, - }, - { - Name: VolumeRepository, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: VolumeRepository, - }, - }, - }, - { - Name: VolumeSecrets, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - DefaultMode: &mode, - SecretName: ResticSecretPasswordName, - }, - }, - }, - { - Name: "volume-volume1", - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: "claim-volume1", - }, - }, - }, - }, - } - - spec, _ := PodSpecBackup(&backup, params, "test-site-id") - assert.Equal(t, expected, spec) -} - -func TestPodSpecRestore(t *testing.T) { - var params = PodSpecParams{ - CPU: "500m", - Memory: "2048Mi", - ResticImage: "test/image", - MySQLImage: "test/mysqlimage", - WorkingDir: "/home/test", - Tags: []string{"tag1"}, - } - restore := extensionv1.Restore{ - ObjectMeta: v1.ObjectMeta{ - Name: "test-restore", - Namespace: "test-namespace", - }, - Spec: extensionv1.RestoreSpec{ - BackupName: "test-backup", - Volumes: map[string]shpmetav1.SpecVolume{ - "volume1": { - ClaimName: "claim-volume1", - }, - }, - MySQL: map[string]shpmetav1.SpecMySQL{ - "mysql1": { - Secret: shpmetav1.SpecMySQLSecret{ - Name: "secret1", - Keys: shpmetav1.SpecMySQLSecretKeys{ - Username: "mysql-user", - Password: "mysql-pass", - Database: "mysql-db", - Hostname: "mysql-host", - Port: "mysql-port", - }, - }, - }, - }, - }, - } - - dc := osv1.DeploymentConfig{ - ObjectMeta: v1.ObjectMeta{ - Name: "test-dc", - Namespace: "test-namespace", - }, - Spec: osv1.DeploymentConfigSpec{ - Template: &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-dc", - Image: "test/deploy-image", - VolumeMounts: []corev1.VolumeMount{ - { - Name: "different-named-volume", - MountPath: "/testmount", - }, - { - Name: "another-unrelated-volume", - MountPath: "/testmount2", - }, - }, - Env: []corev1.EnvVar{ - { - Name: "foo", - Value: "bar", - }, - { - Name: "baz", - Value: "blop", - }, - }, - }, - }, - Volumes: []corev1.Volume{ - { - // Volume with the same claim name as the restore to test names are overwritten. - Name: "different-named-volume", - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: "claim-volume1", - }, - }, - }, - { - Name: "another-unrelated-volume", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }, - }, - }, - }, - }, - } - cpu, _ := resource.ParseQuantity(params.CPU) - memory, _ := resource.ParseQuantity(params.Memory) - mode := corev1.ConfigMapVolumeSourceDefaultMode - resources := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: cpu, - corev1.ResourceMemory: memory, - }, - } - expected := corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - InitContainers: []corev1.Container{ - { - Name: "restic-restore-mysql1", - Image: "test/image", - Resources: resources, - WorkingDir: "/home/test", - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - "restic dump --quiet abcd1234 /mysql/mysql1.sql > ./mysql/mysql1.sql", - }, - Env: []corev1.EnvVar{ - { - Name: EnvResticRepository, - Value: "/srv/backups/test-namespace/test-site-id", - }, - { - Name: EnvResticPasswordFile, - Value: "/etc/restic/password", - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: "/home/test/mysql", - }, - { - Name: VolumeSecrets, - MountPath: SecretDir, - ReadOnly: true, - }, - { - Name: VolumeRepository, - MountPath: ResticRepoDir, - }, - }, - }, - { - Name: "restic-import-mysql1", - Image: "test/mysqlimage", - Resources: resources, - WorkingDir: "/home/test", - Command: []string{ - "database-restore", - }, - Args: []string{ - "mysql/mysql1.sql", - }, - Env: []corev1.EnvVar{ - { - Name: EnvMySQLHostname, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-host", - }, - }, - }, - { - Name: EnvMySQLDatabase, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-db", - }, - }, - }, - { - Name: EnvMySQLPort, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-port", - }, - }, - }, - { - Name: EnvMySQLUsername, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-user", - }, - }, - }, - { - Name: EnvMySQLPassword, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "secret1", - }, - Key: "mysql-pass", - }, - }, - }, - { - Name: EnvResticRepository, - Value: "/srv/backups/test-namespace/test-site-id", - }, - { - Name: EnvResticPasswordFile, - Value: "/etc/restic/password", - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: VolumeMySQL, - MountPath: "/home/test/mysql", - }, - { - Name: VolumeSecrets, - MountPath: SecretDir, - ReadOnly: true, - }, - { - Name: VolumeRepository, - MountPath: ResticRepoDir, - }, - }, - }, - { - Name: "restic-restore-volumes", - Image: "test/image", - Resources: resources, - WorkingDir: "/home/test", - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - "restic restore abcd1234 --target . --include /volume/volume1", - }, - Env: []corev1.EnvVar{ - { - Name: EnvResticRepository, - Value: "/srv/backups/test-namespace/test-site-id", - }, - { - Name: EnvResticPasswordFile, - Value: "/etc/restic/password", - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "volume-volume1", - MountPath: "/home/test/volume/volume1", - }, - { - Name: VolumeSecrets, - MountPath: SecretDir, - ReadOnly: true, - }, - { - Name: VolumeRepository, - MountPath: ResticRepoDir, - }, - }, - }, - }, - Containers: []corev1.Container{ - { - Name: "restore-deploy", - Image: "test/deploy-image", - Resources: resources, - WorkingDir: WebDirectory, - Command: []string{ - "/bin/sh", "-c", - }, - Args: []string{ - "drush -r /code/web cr && drush -r /code/web -y updb && robo config:import-plus && drush -r /code/web cr", - }, - Env: []corev1.EnvVar{ - { - Name: "foo", - Value: "bar", - }, - { - Name: "baz", - Value: "blop", - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "volume-volume1", - MountPath: "/testmount", - }, - { - Name: "another-unrelated-volume", - MountPath: "/testmount2", - }, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: VolumeMySQL, - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{ - Medium: corev1.StorageMediumDefault, - }, - }, - }, - { - Name: VolumeRepository, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: VolumeRepository, - }, - }, - }, - { - Name: VolumeSecrets, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - DefaultMode: &mode, - SecretName: ResticSecretPasswordName, - }, - }, - }, - { - Name: "volume-volume1", - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: "claim-volume1", - }, - }, - }, - { - Name: "another-unrelated-volume", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }, - }, - } - - spec, _ := PodSpecRestore(&restore, &dc, "abcd1234", params, "test-site-id") - assert.Equal(t, expected, spec) - - dc.Spec.Template.Spec.Containers[0].Name = "different name" - _, err := PodSpecRestore(&restore, &dc, "abcd1234", params, "test-site-id") - assert.NotNil(t, err) -} diff --git a/internal/restic/utils.go b/internal/restic/utils.go deleted file mode 100644 index 4b98775a..00000000 --- a/internal/restic/utils.go +++ /dev/null @@ -1,40 +0,0 @@ -package restic - -import ( - "fmt" - "regexp" - "strings" - "time" - - "sigs.k8s.io/controller-runtime/pkg/reconcile" -) - -// Helper function to format tags. -func formatTags(tags []string) string { - var line string - - for _, tag := range tags { - line = fmt.Sprintf("%s --tag=%s", line, tag) - } - - return strings.Trim(line, " ") -} - -// ParseSnapshotID parses the restic snapshot id from a string. -func ParseSnapshotID(input string) string { - // Restic IDs are SHA-256 hashes and the output contains the 8 character short version. - var r = regexp.MustCompile(`snapshot\s([A-Fa-f0-9]{8})\ssaved`) - match := r.FindStringSubmatch(input) - if len(match) <= 1 { - return "" - } - return match[1] -} - -// RequeueAfterSeconds returns a reconcile.Result to requeue after seconds time. -func RequeueAfterSeconds(seconds int64) reconcile.Result { - return reconcile.Result{ - Requeue: true, - RequeueAfter: time.Duration(seconds) * time.Second, - } -} diff --git a/internal/restic/utils_test.go b/internal/restic/utils_test.go deleted file mode 100644 index 991a7fd3..00000000 --- a/internal/restic/utils_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build unit -// +build unit - -package restic - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFormatTags(t *testing.T) { - assert.Equal(t, "--tag=foo", formatTags([]string{"foo"})) - assert.Equal(t, "--tag=foo --tag=bar", formatTags([]string{"foo", "bar"})) -} - -func TestParseResticId(t *testing.T) { - var input = ` -[restic-backup] open repository -[restic-backup] created new cache in /root/.cache/restic -[restic-backup] lock repository -[restic-backup] load index files -[restic-backup] start scan on [.] -[restic-backup] start backup on [.] -[restic-backup] scan finished in 0.804s: 1 files, 1.308 KiB -[restic-backup] -[restic-backup] Files: 1 new, 0 changed, 0 unmodified -[restic-backup] Dirs: 0 new, 0 changed, 0 unmodified -[restic-backup] Data Blobs: 1 new -[restic-backup] Tree Blobs: 1 new -[restic-backup] Added to the repo: 1.993 KiB -[restic-backup] -[restic-backup] processed 1 files, 1.308 KiB in 0:00 -[restic-backup] snapshot 12487f64 saved` - assert.Equal(t, "12487f64", ParseSnapshotID(input)) - input = ` -[restic-backup] open repository -[restic-backup] created new cache in /root/.cache/restic -[restic-backup] lock repository -[restic-backup] load index files -[restic-backup] start scan on [.] -[restic-backup] start backup on [.] -[restic-backup] scan finished in 0.804s: 1 files, 1.308 KiB -[restic-backup] -[restic-backup] Files: 1 new, 0 changed, 0 unmodified -[restic-backup] Dirs: 0 new, 0 changed, 0 unmodified -[restic-backup] Data Blobs: 1 new -[restic-backup] Tree Blobs: 1 new -[restic-backup] Added to the repo: 1.993 KiB -[restic-backup] -[restic-backup] processed 1 files, 1.308 KiB in 0:00` - assert.Equal(t, "", ParseSnapshotID(input)) - assert.Equal(t, "", ParseSnapshotID("snapshot 12487f64ASD saved")) - assert.Equal(t, "12345678", ParseSnapshotID("snapshot 12345678 saved")) - assert.Equal(t, "1a3b5c7d", ParseSnapshotID("snapshot 1a3b5c7d saved")) -} diff --git a/internal/restic/volume.go b/internal/restic/volume.go deleted file mode 100644 index f65aa5f7..00000000 --- a/internal/restic/volume.go +++ /dev/null @@ -1,22 +0,0 @@ -package restic - -import ( - corev1 "k8s.io/api/core/v1" -) - -// AttachVolume will add the Restic secrets volume to a Pod. -func AttachVolume(volumes []corev1.Volume) []corev1.Volume { - mode := corev1.ConfigMapVolumeSourceDefaultMode - - volumes = append(volumes, corev1.Volume{ - Name: VolumeSecrets, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - DefaultMode: &mode, - SecretName: ResticSecretPasswordName, - }, - }, - }) - - return volumes -} diff --git a/main.go b/main.go index a999112a..4d5c1508 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,8 @@ import ( _ "k8s.io/client-go/plugin/pkg/client/auth" osv1client "github.com/openshift/client-go/apps/clientset/versioned/typed/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -37,7 +39,6 @@ import ( "github.com/universityofadelaide/shepherd-operator/controllers/extension/backupscheduled" "github.com/universityofadelaide/shepherd-operator/controllers/extension/restore" "github.com/universityofadelaide/shepherd-operator/controllers/extension/sync" - resticutils "github.com/universityofadelaide/shepherd-operator/internal/restic" //+kubebuilder:scaffold:imports ) @@ -91,17 +92,30 @@ func main() { if err = (&backup.Reconciler{ Client: mgr.GetClient(), - Config: mgr.GetConfig(), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor(backup.ControllerName), Params: backup.Params{ - PodSpec: resticutils.PodSpecParams{ - CPU: os.Getenv("SHEPHERD_OPERATOR_BACKUP_CPU"), - Memory: os.Getenv("SHEPHERD_OPERATOR_BACKUP_MEMORY"), - ResticImage: os.Getenv("SHEPHERD_OPERATOR_BACKUP_RESTIC_IMAGE"), - MySQLImage: os.Getenv("SHEPHERD_OPERATOR_BACKUP_MYSQL_IMAGE"), - WorkingDir: os.Getenv("SHEPHERD_OPERATOR_BACKUP_WORKING_DIR"), - Tags: []string{}, + ResourceRequirements: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_BACKUP_CPU")), + corev1.ResourceMemory: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_BACKUP_MEMORY")), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_BACKUP_CPU")), + corev1.ResourceMemory: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_BACKUP_MEMORY")), + }, + }, + WorkingDir: os.Getenv("SHEPHERD_OPERATOR_BACKUP_WORKING_DIR"), + MySQL: backup.MySQL{ + Image: os.Getenv("SHEPHERD_OPERATOR_BACKUP_MYSQL_IMAGE"), + }, + AWS: backup.AWS{ + Endpoint: os.Getenv("SHEPHERD_OPERATOR_BACKUP_AWS_ENDPOINT"), + BucketName: os.Getenv("SHEPHERD_OPERATOR_BACKUP_AWS_BUCKET_NAME"), + Image: os.Getenv("SHEPHERD_OPERATOR_BACKUP_AWS_IMAGE"), + FieldKeyID: os.Getenv("SHEPHERD_OPERATOR_BACKUP_AWS_KEY_ID"), + FieldAccessKey: os.Getenv("SHEPHERD_OPERATOR_BACKUP_AWS_ACCESS_KEY"), + Region: os.Getenv("SHEPHERD_OPERATOR_BACKUP_AWS_REGION"), }, }, }).SetupWithManager(mgr); err != nil { @@ -113,19 +127,33 @@ func main() { Client: mgr.GetClient(), OpenShift: osclient, Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor(backupscheduled.ControllerName), + Recorder: mgr.GetEventRecorderFor(restore.ControllerName), Params: restore.Params{ - PodSpec: resticutils.PodSpecParams{ - CPU: os.Getenv("SHEPHERD_OPERATOR_RESTORE_CPU"), - Memory: os.Getenv("SHEPHERD_OPERATOR_RESTORE_MEMORY"), - ResticImage: os.Getenv("SHEPHERD_OPERATOR_RESTORE_RESTIC_IMAGE"), - MySQLImage: os.Getenv("SHEPHERD_OPERATOR_RESTORE_MYSQL_IMAGE"), - WorkingDir: os.Getenv("SHEPHERD_OPERATOR_RESTORE_WORKING_DIR"), - Tags: []string{}, + ResourceRequirements: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_RESTORE_CPU")), + corev1.ResourceMemory: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_RESTORE_MEMORY")), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_RESTORE_CPU")), + corev1.ResourceMemory: resource.MustParse(os.Getenv("SHEPHERD_OPERATOR_RESTORE_MEMORY")), + }, + }, + WorkingDir: os.Getenv("SHEPHERD_OPERATOR_RESTORE_WORKING_DIR"), + MySQL: restore.MySQL{ + Image: os.Getenv("SHEPHERD_OPERATOR_RESTORE_MYSQL_IMAGE"), + }, + AWS: restore.AWS{ + Endpoint: os.Getenv("SHEPHERD_OPERATOR_RESTORE_AWS_ENDPOINT"), + BucketName: os.Getenv("SHEPHERD_OPERATOR_RESTORE_AWS_BUCKET_NAME"), + Image: os.Getenv("SHEPHERD_OPERATOR_RESTORE_AWS_IMAGE"), + FieldKeyID: os.Getenv("SHEPHERD_OPERATOR_RESTORE_AWS_KEY_ID"), + FieldAccessKey: os.Getenv("SHEPHERD_OPERATOR_RESTORE_AWS_ACCESS_KEY"), + Region: os.Getenv("SHEPHERD_OPERATOR_RESTORE_AWS_REGION"), }, }, }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", backupscheduled.ControllerName) + setupLog.Error(err, "unable to create controller", "controller", restore.ControllerName) os.Exit(1) }