diff --git a/controllers/backupcronjob/backupcronjob_controller.go b/controllers/backupcronjob/backupcronjob_controller.go index 7012e13f0..e0445defa 100644 --- a/controllers/backupcronjob/backupcronjob_controller.go +++ b/controllers/backupcronjob/backupcronjob_controller.go @@ -26,13 +26,11 @@ import ( dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" "github.com/devfile/devworkspace-operator/internal/images" - "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/conditions" "github.com/devfile/devworkspace-operator/pkg/config" - wkspConfig "github.com/devfile/devworkspace-operator/pkg/config" "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/infrastructure" - "github.com/devfile/devworkspace-operator/pkg/provision/storage" + "github.com/devfile/devworkspace-operator/pkg/library/storage" "github.com/go-logr/logr" "github.com/robfig/cron/v3" batchv1 "k8s.io/api/batch/v1" @@ -353,14 +351,15 @@ func (r *BackupCronJobReconciler) createBackupJob( } // Find a PVC with used by the workspace - pvcName, workspacePath, err := r.getWorkspacePVCName(ctx, workspace, dwOperatorConfig, log) + pvcName, workspacePath, err := storage.GetWorkspacePVCInfo(ctx, workspace, dwOperatorConfig.Config, r.Client, log) if err != nil { log.Error(err, "Failed to get workspace PVC name", "devworkspace", workspace.Name) return err } if pvcName == "" { - log.Error(err, "No PVC found for DevWorkspace", "id", dwID) - return err + // No PVC to back up + log.Info("No workspace PVC found, skipping backup", "devworkspace", workspace.Name) + return nil } pvc := &corev1.PersistentVolumeClaim{} @@ -482,32 +481,6 @@ func (r *BackupCronJobReconciler) createBackupJob( return nil } -// getWorkspacePVCName determines the PVC name and workspace path based on the storage provisioner used. -func (r *BackupCronJobReconciler) getWorkspacePVCName(ctx context.Context, workspace *dw.DevWorkspace, dwOperatorConfig *controllerv1alpha1.DevWorkspaceOperatorConfig, log logr.Logger) (string, string, error) { - config, err := wkspConfig.ResolveConfigForWorkspace(workspace, r.Client) - - workspaceWithConfig := &common.DevWorkspaceWithConfig{} - workspaceWithConfig.DevWorkspace = workspace - workspaceWithConfig.Config = config - - storageProvisioner, err := storage.GetProvisioner(workspaceWithConfig) - if err != nil { - return "", "", err - } - if _, ok := storageProvisioner.(*storage.PerWorkspaceStorageProvisioner); ok { - pvcName := common.PerWorkspacePVCName(workspace.Status.DevWorkspaceId) - return pvcName, constants.DefaultProjectsSourcesRoot, nil - - } else if _, ok := storageProvisioner.(*storage.CommonStorageProvisioner); ok { - pvcName := constants.DefaultWorkspacePVCName - if dwOperatorConfig.Config.Workspace.PVCName != "" { - pvcName = dwOperatorConfig.Config.Workspace.PVCName - } - return pvcName, workspace.Status.DevWorkspaceId + constants.DefaultProjectsSourcesRoot, nil - } - return "", "", nil -} - func (r *BackupCronJobReconciler) handleRegistryAuthSecret(ctx context.Context, workspace *dw.DevWorkspace, dwOperatorConfig *controllerv1alpha1.DevWorkspaceOperatorConfig, log logr.Logger, ) (*corev1.Secret, error) { diff --git a/controllers/backupcronjob/backupcronjob_controller_test.go b/controllers/backupcronjob/backupcronjob_controller_test.go index cb2522def..f223017e7 100644 --- a/controllers/backupcronjob/backupcronjob_controller_test.go +++ b/controllers/backupcronjob/backupcronjob_controller_test.go @@ -522,6 +522,39 @@ var _ = Describe("BackupCronJobReconciler", func() { Expect(fakeClient.List(ctx, jobList, &client.ListOptions{Namespace: dw.Namespace})).To(Succeed()) Expect(jobList.Items).To(HaveLen(1)) }) + It("It doesn't create a Job for a DevWorkspace with no PVC", func() { + enabled := true + schedule := "* * * * *" + dwoc := &controllerv1alpha1.DevWorkspaceOperatorConfig{ + ObjectMeta: metav1.ObjectMeta{Name: nameNamespace.Name, Namespace: nameNamespace.Namespace}, + Config: &controllerv1alpha1.OperatorConfiguration{ + Workspace: &controllerv1alpha1.WorkspaceConfig{ + BackupCronJob: &controllerv1alpha1.BackupCronJobConfig{ + Enable: &enabled, + Schedule: schedule, + Registry: &controllerv1alpha1.RegistryConfig{ + Path: "fake-registry", + }, + OrasConfig: &controllerv1alpha1.OrasConfig{ + ExtraArgs: "--extra-arg1", + }, + }, + }, + }, + } + Expect(fakeClient.Create(ctx, dwoc)).To(Succeed()) + dw := createDevWorkspace("dw-recent", "ns-a", false, metav1.NewTime(time.Now().Add(-10*time.Minute))) + dw.Spec.Template.Components = []dwv2.Component{} // No volume component, so no PVC + dw.Status.Phase = dwv2.DevWorkspaceStatusStopped + dw.Status.DevWorkspaceId = "id-recent" + Expect(fakeClient.Create(ctx, dw)).To(Succeed()) + + Expect(reconciler.executeBackupSync(ctx, dwoc, log)).To(Succeed()) + + jobList := &batchv1.JobList{} + Expect(fakeClient.List(ctx, jobList, &client.ListOptions{Namespace: dw.Namespace})).To(Succeed()) + Expect(jobList.Items).To(HaveLen(0)) + }) }) Context("ensureJobRunnerRBAC", func() { It("creates ServiceAccount for Job runner", func() { @@ -907,6 +940,28 @@ func createDevWorkspace(name, namespace string, started bool, lastTransitionTime }, Spec: dwv2.DevWorkspaceSpec{ Started: started, + Template: dwv2.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dwv2.DevWorkspaceTemplateSpecContent{ + Components: []dwv2.Component{ + { + Name: "test-container", + ComponentUnion: dwv2.ComponentUnion{ + Container: &dwv2.ContainerComponent{ + Container: dwv2.Container{ + Image: "test-image:latest", + }, + }, + Volume: &dwv2.VolumeComponent{ + Volume: dwv2.Volume{ + Ephemeral: pointer.BoolPtr(true), + Size: "1Mi", + }, + }, + }, + }, + }, + }, + }, }, Status: dwv2.DevWorkspaceStatus{ Conditions: []dwv2.DevWorkspaceCondition{}, diff --git a/pkg/library/storage/storage.go b/pkg/library/storage/storage.go new file mode 100644 index 000000000..f32aecb91 --- /dev/null +++ b/pkg/library/storage/storage.go @@ -0,0 +1,69 @@ +// +// Copyright (c) 2019-2025 Red Hat, Inc. +// 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 storage + +import ( + "context" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/provision/storage" + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// GetWorkspacePVCInfo determines the PVC name and workspace path based on the storage provisioner used. +// This function can be used by both backup and restore operations to ensure consistent PVC resolution logic. +// +// Returns: +// - pvcName: The name of the PVC that stores workspace data +// - workspacePath: The path within the PVC where workspace data is stored +// - error: Any error that occurred during PVC resolution +func GetWorkspacePVCInfo( + ctx context.Context, + workspace *dw.DevWorkspace, + config *controllerv1alpha1.OperatorConfiguration, + k8sClient client.Client, + log logr.Logger, +) (pvcName string, workspacePath string, err error) { + workspaceWithConfig := &common.DevWorkspaceWithConfig{} + workspaceWithConfig.DevWorkspace = workspace + workspaceWithConfig.Config = config + + storageProvisioner, err := storage.GetProvisioner(workspaceWithConfig) + if err != nil { + return "", "", err + } + if !storageProvisioner.NeedsStorage(&workspace.Spec.Template) { + // No storage provisioned for this workspace + return "", "", nil + } + + if _, ok := storageProvisioner.(*storage.PerWorkspaceStorageProvisioner); ok { + pvcName := common.PerWorkspacePVCName(workspace.Status.DevWorkspaceId) + return pvcName, constants.DefaultProjectsSourcesRoot, nil + + } else if _, ok := storageProvisioner.(*storage.CommonStorageProvisioner); ok { + pvcName := constants.DefaultWorkspacePVCName + if config.Workspace != nil && config.Workspace.PVCName != "" { + pvcName = config.Workspace.PVCName + } + return pvcName, workspace.Status.DevWorkspaceId + constants.DefaultProjectsSourcesRoot, nil + } + return "", "", nil +}