diff --git a/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go b/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go index bdbcdf377..2ea24278d 100644 --- a/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go +++ b/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go @@ -144,6 +144,9 @@ type WorkspaceConfig struct { // ProjectCloneConfig defines configuration related to the project clone init container // that is used to clone git projects into the DevWorkspace. ProjectCloneConfig *ProjectCloneConfig `json:"projectClone,omitempty"` + // RestoreConfig defines configuration related to the workspace restore init container + // that is used to restore workspace data from a backup image. + RestoreConfig *RestoreConfig `json:"restore,omitempty"` // ImagePullPolicy defines the imagePullPolicy used for containers in a DevWorkspace // For additional information, see Kubernetes documentation for imagePullPolicy. If // not specified, the default value of "Always" is used. @@ -376,6 +379,18 @@ type ProjectCloneConfig struct { Env []corev1.EnvVar `json:"env,omitempty"` } +type RestoreConfig struct { + // ImagePullPolicy configures the imagePullPolicy for the restore container. + // If undefined, the general setting .config.workspace.imagePullPolicy is used instead. + ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` + // Resources defines the resource (cpu, memory) limits and requests for the restore + // container. To explicitly not specify a limit or request, define the resource + // quantity as zero ('0') + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + // Env allows defining additional environment variables for the restore container. + Env []corev1.EnvVar `json:"env,omitempty"` +} + type ConfigmapReference struct { // Name is the name of the configmap Name string `json:"name"` diff --git a/apis/controller/v1alpha1/zz_generated.deepcopy.go b/apis/controller/v1alpha1/zz_generated.deepcopy.go index 00f941bcb..fac1c4ed7 100644 --- a/apis/controller/v1alpha1/zz_generated.deepcopy.go +++ b/apis/controller/v1alpha1/zz_generated.deepcopy.go @@ -676,6 +676,33 @@ func (in *RegistryConfig) DeepCopy() *RegistryConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RestoreConfig) DeepCopyInto(out *RestoreConfig) { + *out = *in + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(v1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]v1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RestoreConfig. +func (in *RestoreConfig) DeepCopy() *RestoreConfig { + if in == nil { + return nil + } + out := new(RestoreConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RoutingConfig) DeepCopyInto(out *RoutingConfig) { *out = *in @@ -808,6 +835,11 @@ func (in *WorkspaceConfig) DeepCopyInto(out *WorkspaceConfig) { *out = new(ProjectCloneConfig) (*in).DeepCopyInto(*out) } + if in.RestoreConfig != nil { + in, out := &in.RestoreConfig, &out.RestoreConfig + *out = new(RestoreConfig) + (*in).DeepCopyInto(*out) + } if in.ServiceAccount != nil { in, out := &in.ServiceAccount, &out.ServiceAccount *out = new(ServiceAccountConfig) diff --git a/controllers/backupcronjob/backupcronjob_controller.go b/controllers/backupcronjob/backupcronjob_controller.go index e0445defa..1d77bce2a 100644 --- a/controllers/backupcronjob/backupcronjob_controller.go +++ b/controllers/backupcronjob/backupcronjob_controller.go @@ -31,6 +31,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/infrastructure" "github.com/devfile/devworkspace-operator/pkg/library/storage" + "github.com/devfile/devworkspace-operator/pkg/secrets" "github.com/go-logr/logr" "github.com/robfig/cron/v3" batchv1 "k8s.io/api/batch/v1" @@ -344,7 +345,7 @@ func (r *BackupCronJobReconciler) createBackupJob( dwID := workspace.Status.DevWorkspaceId backUpConfig := dwOperatorConfig.Config.Workspace.BackupCronJob - registryAuthSecret, err := r.handleRegistryAuthSecret(ctx, workspace, dwOperatorConfig, log) + registryAuthSecret, err := secrets.HandleRegistryAuthSecret(ctx, r.Client, workspace, dwOperatorConfig.Config, dwOperatorConfig.Namespace, r.Scheme, log) if err != nil { log.Error(err, "Failed to handle registry auth secret for DevWorkspace", "devworkspace", workspace.Name) return err @@ -480,77 +481,3 @@ func (r *BackupCronJobReconciler) createBackupJob( log.Info("Created backup Job for DevWorkspace", "jobName", job.Name, "devworkspace", workspace.Name) return nil } - -func (r *BackupCronJobReconciler) handleRegistryAuthSecret(ctx context.Context, workspace *dw.DevWorkspace, - dwOperatorConfig *controllerv1alpha1.DevWorkspaceOperatorConfig, log logr.Logger, -) (*corev1.Secret, error) { - secretName := dwOperatorConfig.Config.Workspace.BackupCronJob.Registry.AuthSecret - if secretName == "" { - // No auth secret configured - anonymous access to registry - return nil, nil - } - - // First check the workspace namespace for the secret - registryAuthSecret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{ - Name: secretName, - Namespace: workspace.Namespace}, registryAuthSecret) - if err == nil { - log.Info("Successfully retrieved registry auth secret for backup from workspace namespace", "secretName", secretName) - return registryAuthSecret, nil - } - if client.IgnoreNotFound(err) != nil { - return nil, err - } - - log.Info("Registry auth secret not found in workspace namespace, checking operator namespace", "secretName", secretName) - - // If the secret is not found in the workspace namespace, check the operator namespace as fallback - err = r.Get(ctx, client.ObjectKey{ - Name: secretName, - Namespace: dwOperatorConfig.Namespace}, registryAuthSecret) - if err != nil { - log.Error(err, "Failed to get registry auth secret for backup job", "secretName", secretName) - return nil, err - } - log.Info("Successfully retrieved registry auth secret for backup job", "secretName", secretName) - return r.copySecret(ctx, workspace, registryAuthSecret, log) -} - -// copySecret copies the given secret from the operator namespace to the workspace namespace. -func (r *BackupCronJobReconciler) copySecret(ctx context.Context, workspace *dw.DevWorkspace, sourceSecret *corev1.Secret, log logr.Logger) (namespaceSecret *corev1.Secret, err error) { - existingNamespaceSecret := &corev1.Secret{} - err = r.Get(ctx, client.ObjectKey{ - Name: constants.DevWorkspaceBackupAuthSecretName, - Namespace: workspace.Namespace}, existingNamespaceSecret) - if client.IgnoreNotFound(err) != nil { - log.Error(err, "Failed to check for existing registry auth secret in workspace namespace", "namespace", workspace.Namespace) - return nil, err - } - if err == nil { - err = r.Delete(ctx, existingNamespaceSecret) - if err != nil { - return nil, err - } - } - namespaceSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: constants.DevWorkspaceBackupAuthSecretName, - Namespace: workspace.Namespace, - Labels: map[string]string{ - constants.DevWorkspaceIDLabel: workspace.Status.DevWorkspaceId, - constants.DevWorkspaceWatchSecretLabel: "true", - }, - }, - Data: sourceSecret.Data, - Type: sourceSecret.Type, - } - if err := controllerutil.SetControllerReference(workspace, namespaceSecret, r.Scheme); err != nil { - return nil, err - } - err = r.Create(ctx, namespaceSecret) - if err == nil { - log.Info("Successfully created secret", "name", namespaceSecret.Name, "namespace", workspace.Namespace) - } - return namespaceSecret, err -} diff --git a/controllers/backupcronjob/rbac.go b/controllers/backupcronjob/rbac.go index 9f1dfcf3b..a04464abb 100644 --- a/controllers/backupcronjob/rbac.go +++ b/controllers/backupcronjob/rbac.go @@ -135,9 +135,6 @@ func (r *BackupCronJobReconciler) ensureImageStreamForBackup(ctx context.Context }, }, } - if err := controllerutil.SetControllerReference(workspace, imageStream, r.Scheme); err != nil { - return err - } imageStream.SetGroupVersionKind(schema.GroupVersionKind{ Group: "image.openshift.io", diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index eaa366d0b..560243e08 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -42,6 +42,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/library/home" kubesync "github.com/devfile/devworkspace-operator/pkg/library/kubernetes" "github.com/devfile/devworkspace-operator/pkg/library/projects" + "github.com/devfile/devworkspace-operator/pkg/library/restore" "github.com/devfile/devworkspace-operator/pkg/library/status" "github.com/devfile/devworkspace-operator/pkg/provision/automount" "github.com/devfile/devworkspace-operator/pkg/provision/metadata" @@ -353,21 +354,51 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request if err := projects.ValidateAllProjects(&workspace.Spec.Template); err != nil { return r.failWorkspace(workspace, fmt.Sprintf("Invalid devfile: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil } - // Add init container to clone projects - projectCloneOptions := projects.Options{ - Image: workspace.Config.Workspace.ProjectCloneConfig.Image, - Env: env.GetEnvironmentVariablesForProjectClone(workspace), - Resources: workspace.Config.Workspace.ProjectCloneConfig.Resources, - } - if workspace.Config.Workspace.ProjectCloneConfig.ImagePullPolicy != "" { - projectCloneOptions.PullPolicy = config.Workspace.ProjectCloneConfig.ImagePullPolicy + if restore.IsWorkspaceRestoreRequested(&workspace.Spec.Template) { + // Add init container to restore workspace from backup if requested + restoreOptions := restore.Options{ + Env: env.GetEnvironmentVariablesForProjectRestore(workspace), + Resources: workspace.Config.Workspace.RestoreConfig.Resources, + } + if config.Workspace.ImagePullPolicy != "" { + restoreOptions.PullPolicy = corev1.PullPolicy(config.Workspace.ImagePullPolicy) + } else { + restoreOptions.PullPolicy = corev1.PullIfNotPresent + } + if workspaceRestore, registryAuthSecret, err := restore.GetWorkspaceRestoreInitContainer(ctx, workspace, r.Client, restoreOptions, r.Scheme, reqLogger); err != nil { + return r.failWorkspace(workspace, fmt.Sprintf("Failed to set up workspace-restore init container: %s", err), metrics.ReasonInfrastructureFailure, reqLogger, &reconcileStatus), nil + } else if workspaceRestore != nil { + devfilePodAdditions.InitContainers = append([]corev1.Container{*workspaceRestore}, devfilePodAdditions.InitContainers...) + if registryAuthSecret != nil { + // Add the registry auth secret volume + devfilePodAdditions.Volumes = append(devfilePodAdditions.Volumes, corev1.Volume{ + Name: "registry-auth-secret", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: registryAuthSecret.Name, // You may want to make this configurable + }, + }, + }) + } + + } } else { - projectCloneOptions.PullPolicy = corev1.PullPolicy(config.Workspace.ImagePullPolicy) - } - if projectClone, err := projects.GetProjectCloneInitContainer(&workspace.Spec.Template, projectCloneOptions, workspace.Config.Routing.ProxyConfig); err != nil { - return r.failWorkspace(workspace, fmt.Sprintf("Failed to set up project-clone init container: %s", err), metrics.ReasonInfrastructureFailure, reqLogger, &reconcileStatus), nil - } else if projectClone != nil { - devfilePodAdditions.InitContainers = append([]corev1.Container{*projectClone}, devfilePodAdditions.InitContainers...) + // Add init container to clone projects only if restore container wasn't created + projectCloneOptions := projects.Options{ + Image: workspace.Config.Workspace.ProjectCloneConfig.Image, + Env: env.GetEnvironmentVariablesForProjectClone(workspace), + Resources: workspace.Config.Workspace.ProjectCloneConfig.Resources, + } + if workspace.Config.Workspace.ProjectCloneConfig.ImagePullPolicy != "" { + projectCloneOptions.PullPolicy = config.Workspace.ProjectCloneConfig.ImagePullPolicy + } else { + projectCloneOptions.PullPolicy = corev1.PullPolicy(config.Workspace.ImagePullPolicy) + } + if projectClone, err := projects.GetProjectCloneInitContainer(&workspace.Spec.Template, projectCloneOptions, workspace.Config.Routing.ProxyConfig); err != nil { + return r.failWorkspace(workspace, fmt.Sprintf("Failed to set up project-clone init container: %s", err), metrics.ReasonInfrastructureFailure, reqLogger, &reconcileStatus), nil + } else if projectClone != nil { + devfilePodAdditions.InitContainers = append([]corev1.Container{*projectClone}, devfilePodAdditions.InitContainers...) + } } // Inject operator-configured init containers diff --git a/controllers/workspace/devworkspace_controller_test.go b/controllers/workspace/devworkspace_controller_test.go index 9980a227e..2ed223b12 100644 --- a/controllers/workspace/devworkspace_controller_test.go +++ b/controllers/workspace/devworkspace_controller_test.go @@ -28,6 +28,8 @@ import ( "github.com/devfile/devworkspace-operator/pkg/conditions" "github.com/devfile/devworkspace-operator/pkg/config" "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/library/projects" + "github.com/devfile/devworkspace-operator/pkg/library/restore" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" @@ -36,6 +38,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" ) @@ -1024,6 +1027,204 @@ var _ = Describe("DevWorkspace Controller", func() { }) }) + Context("Workspace Restore", func() { + const testURL = "test-url" + + BeforeEach(func() { + workspacecontroller.SetupHttpClientsForTesting(&http.Client{ + Transport: &testutil.TestRoundTripper{ + Data: map[string]testutil.TestResponse{ + fmt.Sprintf("%s/healthz", testURL): { + StatusCode: http.StatusOK, + }, + }, + }, + }) + }) + + AfterEach(func() { + deleteDevWorkspace(devWorkspaceName) + workspacecontroller.SetupHttpClientsForTesting(getBasicTestHttpClient()) + }) + + It("Restores workspace from backup with common PVC", func() { + config.SetGlobalConfigForTesting(&controllerv1alpha1.OperatorConfiguration{ + Workspace: &controllerv1alpha1.WorkspaceConfig{ + BackupCronJob: &controllerv1alpha1.BackupCronJobConfig{ + Enable: ptr.To[bool](true), + Registry: &controllerv1alpha1.RegistryConfig{ + Path: "localhost:5000", + }, + }, + }, + }) + defer config.SetGlobalConfigForTesting(nil) + By("Reading DevWorkspace with restore configuration from testdata file") + createDevWorkspace(devWorkspaceName, "restore-workspace-common.yaml") + devworkspace := getExistingDevWorkspace(devWorkspaceName) + workspaceID := devworkspace.Status.DevWorkspaceId + + By("Waiting for DevWorkspaceRouting to be created") + dwr := &controllerv1alpha1.DevWorkspaceRouting{} + dwrName := common.DevWorkspaceRoutingName(workspaceID) + Eventually(func() error { + return k8sClient.Get(ctx, namespacedName(dwrName, testNamespace), dwr) + }, timeout, interval).Should(Succeed(), "DevWorkspaceRouting should be created") + + By("Manually making Routing ready to continue") + markRoutingReady(testURL, common.DevWorkspaceRoutingName(workspaceID)) + + By("Setting the deployment to have 1 ready replica") + markDeploymentReady(common.DeploymentName(devworkspace.Status.DevWorkspaceId)) + + deployment := &appsv1.Deployment{} + err := k8sClient.Get(ctx, namespacedName(devworkspace.Status.DevWorkspaceId, devworkspace.Namespace), deployment) + Expect(err).ToNot(HaveOccurred(), "Failed to get DevWorkspace deployment") + + initContainers := deployment.Spec.Template.Spec.InitContainers + Expect(len(initContainers)).To(BeNumerically(">", 0), "No initContainers found in deployment") + + var restoreInitContainer corev1.Container + var cloneInitContainer corev1.Container + for _, container := range initContainers { + if container.Name == restore.WorkspaceRestoreContainerName { + restoreInitContainer = container + } + if container.Name == projects.ProjectClonerContainerName { + cloneInitContainer = container + } + } + Expect(cloneInitContainer.Name).To(BeEmpty(), "Project clone init container should be omitted when restoring from backup") + Expect(restoreInitContainer).ToNot(BeNil(), "Workspace restore init container should not be nil") + Expect(restoreInitContainer.Name).To(Equal(restore.WorkspaceRestoreContainerName), "Workspace restore init container should be present in deployment") + + Expect(restoreInitContainer.Command).To(Equal([]string{"/workspace-recovery.sh"}), "Restore init container should have correct command") + Expect(restoreInitContainer.Args).To(Equal([]string{"--restore"}), "Restore init container should have correct args") + Expect(restoreInitContainer.VolumeMounts).To(ContainElement(corev1.VolumeMount{ + Name: "claim-devworkspace", // PVC name for common storage + MountPath: constants.DefaultProjectsSourcesRoot, + ReadOnly: false, + SubPath: workspaceID + "/projects", // Dynamic workspace ID + projects + SubPathExpr: "", + }), "Restore init container should have workspace storage volume mounted at correct path") + + }) + It("Restores workspace from backup with per-workspace PVC", func() { + config.SetGlobalConfigForTesting(&controllerv1alpha1.OperatorConfiguration{ + Workspace: &controllerv1alpha1.WorkspaceConfig{ + BackupCronJob: &controllerv1alpha1.BackupCronJobConfig{ + Enable: ptr.To[bool](true), + Registry: &controllerv1alpha1.RegistryConfig{ + Path: "localhost:5000", + }, + }, + }, + }) + defer config.SetGlobalConfigForTesting(nil) + By("Reading DevWorkspace with restore configuration from testdata file") + createDevWorkspace(devWorkspaceName, "restore-workspace-perworkspace.yaml") + devworkspace := getExistingDevWorkspace(devWorkspaceName) + workspaceID := devworkspace.Status.DevWorkspaceId + + By("Waiting for DevWorkspaceRouting to be created") + dwr := &controllerv1alpha1.DevWorkspaceRouting{} + dwrName := common.DevWorkspaceRoutingName(workspaceID) + Eventually(func() error { + return k8sClient.Get(ctx, namespacedName(dwrName, testNamespace), dwr) + }, timeout, interval).Should(Succeed(), "DevWorkspaceRouting should be created") + + By("Manually making Routing ready to continue") + markRoutingReady(testURL, common.DevWorkspaceRoutingName(workspaceID)) + + By("Setting the deployment to have 1 ready replica") + markDeploymentReady(common.DeploymentName(devworkspace.Status.DevWorkspaceId)) + + deployment := &appsv1.Deployment{} + err := k8sClient.Get(ctx, namespacedName(devworkspace.Status.DevWorkspaceId, devworkspace.Namespace), deployment) + Expect(err).ToNot(HaveOccurred(), "Failed to get DevWorkspace deployment") + + initContainers := deployment.Spec.Template.Spec.InitContainers + Expect(len(initContainers)).To(BeNumerically(">", 0), "No initContainers found in deployment") + + var restoreInitContainer corev1.Container + var cloneInitContainer corev1.Container + for _, container := range initContainers { + if container.Name == restore.WorkspaceRestoreContainerName { + restoreInitContainer = container + } + if container.Name == projects.ProjectClonerContainerName { + cloneInitContainer = container + } + } + Expect(cloneInitContainer.Name).To(BeEmpty(), "Project clone init container should be omitted when restoring from backup") + Expect(restoreInitContainer).ToNot(BeNil(), "Workspace restore init container should not be nil") + Expect(restoreInitContainer.Name).To(Equal(restore.WorkspaceRestoreContainerName), "Workspace restore init container should be present in deployment") + + Expect(restoreInitContainer.Command).To(Equal([]string{"/workspace-recovery.sh"}), "Restore init container should have correct command") + Expect(restoreInitContainer.Args).To(Equal([]string{"--restore"}), "Restore init container should have correct args") + Expect(restoreInitContainer.VolumeMounts).To(ContainElement(corev1.VolumeMount{ + Name: common.PerWorkspacePVCName(workspaceID), + MountPath: constants.DefaultProjectsSourcesRoot, + ReadOnly: false, + SubPath: "projects", + SubPathExpr: "", + }), "Restore init container should have workspace storage volume mounted at correct path") + + }) + It("Doesn't restore workspace from backup if restore is disabled", func() { + config.SetGlobalConfigForTesting(&controllerv1alpha1.OperatorConfiguration{ + Workspace: &controllerv1alpha1.WorkspaceConfig{ + BackupCronJob: &controllerv1alpha1.BackupCronJobConfig{ + Enable: ptr.To[bool](true), + Registry: &controllerv1alpha1.RegistryConfig{ + Path: "localhost:5000", + }, + }, + }, + }) + defer config.SetGlobalConfigForTesting(nil) + By("Reading DevWorkspace with restore configuration from testdata file") + createDevWorkspace(devWorkspaceName, "restore-workspace-disabled.yaml") + devworkspace := getExistingDevWorkspace(devWorkspaceName) + workspaceID := devworkspace.Status.DevWorkspaceId + + By("Waiting for DevWorkspaceRouting to be created") + dwr := &controllerv1alpha1.DevWorkspaceRouting{} + dwrName := common.DevWorkspaceRoutingName(workspaceID) + Eventually(func() error { + return k8sClient.Get(ctx, namespacedName(dwrName, testNamespace), dwr) + }, timeout, interval).Should(Succeed(), "DevWorkspaceRouting should be created") + + By("Manually making Routing ready to continue") + markRoutingReady(testURL, common.DevWorkspaceRoutingName(workspaceID)) + + By("Setting the deployment to have 1 ready replica") + markDeploymentReady(common.DeploymentName(devworkspace.Status.DevWorkspaceId)) + + deployment := &appsv1.Deployment{} + err := k8sClient.Get(ctx, namespacedName(devworkspace.Status.DevWorkspaceId, devworkspace.Namespace), deployment) + Expect(err).ToNot(HaveOccurred(), "Failed to get DevWorkspace deployment") + + initContainers := deployment.Spec.Template.Spec.InitContainers + Expect(len(initContainers)).To(BeNumerically(">", 0), "No initContainers found in deployment") + + var restoreInitContainer corev1.Container + var cloneInitContainer corev1.Container + for _, container := range initContainers { + if container.Name == restore.WorkspaceRestoreContainerName { + restoreInitContainer = container + } + if container.Name == projects.ProjectClonerContainerName { + cloneInitContainer = container + } + } + Expect(restoreInitContainer.Name).To(BeEmpty(), "Workspace restore init container should be omitted when restore is disabled") + Expect(cloneInitContainer).ToNot(BeNil(), "Project clone init container should not be nil") + + }) + + }) + Context("Edge cases", func() { It("Allows Kubernetes and Container components to share same target port on endpoint", func() { diff --git a/controllers/workspace/testdata/restore-workspace-common.yaml b/controllers/workspace/testdata/restore-workspace-common.yaml new file mode 100644 index 000000000..732650878 --- /dev/null +++ b/controllers/workspace/testdata/restore-workspace-common.yaml @@ -0,0 +1,27 @@ +kind: DevWorkspace +apiVersion: workspace.devfile.io/v1alpha2 +metadata: + labels: + controller.devfile.io/creator: "" +spec: + started: true + routingClass: 'basic' + template: + attributes: + controller.devfile.io/storage-type: common + controller.devfile.io/restore-workspace: 'true' + projects: + - name: web-nodejs-sample + git: + remotes: + origin: "https://github.com/che-samples/web-nodejs-sample.git" + components: + - name: web-terminal + container: + image: quay.io/wto/web-terminal-tooling:latest + memoryLimit: 512Mi + mountSources: true + command: + - "tail" + - "-f" + - "/dev/null" diff --git a/controllers/workspace/testdata/restore-workspace-disabled.yaml b/controllers/workspace/testdata/restore-workspace-disabled.yaml new file mode 100644 index 000000000..2fcf80a17 --- /dev/null +++ b/controllers/workspace/testdata/restore-workspace-disabled.yaml @@ -0,0 +1,27 @@ +kind: DevWorkspace +apiVersion: workspace.devfile.io/v1alpha2 +metadata: + labels: + controller.devfile.io/creator: "" +spec: + started: true + routingClass: 'basic' + template: + attributes: + controller.devfile.io/storage-type: common + controller.devfile.io/restore-workspace: 'false' + projects: + - name: web-nodejs-sample + git: + remotes: + origin: "https://github.com/che-samples/web-nodejs-sample.git" + components: + - name: web-terminal + container: + image: quay.io/wto/web-terminal-tooling:latest + memoryLimit: 512Mi + mountSources: true + command: + - "tail" + - "-f" + - "/dev/null" diff --git a/controllers/workspace/testdata/restore-workspace-perworkspace.yaml b/controllers/workspace/testdata/restore-workspace-perworkspace.yaml new file mode 100644 index 000000000..29b8ebc2b --- /dev/null +++ b/controllers/workspace/testdata/restore-workspace-perworkspace.yaml @@ -0,0 +1,30 @@ +kind: DevWorkspace +apiVersion: workspace.devfile.io/v1alpha2 +metadata: + labels: + controller.devfile.io/creator: "" +spec: + started: true + routingClass: 'basic' + template: + attributes: + controller.devfile.io/storage-type: per-workspace + controller.devfile.io/restore-workspace: 'true' + projects: + - name: web-nodejs-sample + git: + remotes: + origin: "https://github.com/che-samples/web-nodejs-sample.git" + components: + - volume: + size: 1Gi + name: projects + - name: web-terminal + container: + image: quay.io/wto/web-terminal-tooling:latest + memoryLimit: 512Mi + mountSources: true + command: + - "tail" + - "-f" + - "/dev/null" diff --git a/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml b/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml index dceb20d7a..e98b01d16 100644 --- a/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml +++ b/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml @@ -4507,6 +4507,226 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + restore: + description: |- + RestoreConfig defines configuration related to the workspace restore init container + that is used to restore workspace data from a backup image. + properties: + env: + description: Env allows defining additional environment variables for the restore container. + items: + description: EnvVar represents an environment variable present in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's namespace + properties: + key: + description: The key of the secret to select from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + imagePullPolicy: + description: |- + ImagePullPolicy configures the imagePullPolicy for the restore container. + If undefined, the general setting .config.workspace.imagePullPolicy is used instead. + type: string + resources: + description: |- + Resources defines the resource (cpu, memory) limits and requests for the restore + container. To explicitly not specify a limit or request, define the resource + quantity as zero ('0') + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object runtimeClassName: description: RuntimeClassName defines the spec.runtimeClassName for DevWorkspace pods created by the DevWorkspace Operator. type: string diff --git a/deploy/deployment/kubernetes/combined.yaml b/deploy/deployment/kubernetes/combined.yaml index bab3c69ee..594673280 100644 --- a/deploy/deployment/kubernetes/combined.yaml +++ b/deploy/deployment/kubernetes/combined.yaml @@ -4727,6 +4727,238 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + restore: + description: |- + RestoreConfig defines configuration related to the workspace restore init container + that is used to restore workspace data from a backup image. + properties: + env: + description: Env allows defining additional environment variables + for the restore container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + imagePullPolicy: + description: |- + ImagePullPolicy configures the imagePullPolicy for the restore container. + If undefined, the general setting .config.workspace.imagePullPolicy is used instead. + type: string + resources: + description: |- + Resources defines the resource (cpu, memory) limits and requests for the restore + container. To explicitly not specify a limit or request, define the resource + quantity as zero ('0') + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object runtimeClassName: description: RuntimeClassName defines the spec.runtimeClassName for DevWorkspace pods created by the DevWorkspace Operator. diff --git a/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml b/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml index dd5a8b46e..43c86f02a 100644 --- a/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml +++ b/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml @@ -4727,6 +4727,238 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + restore: + description: |- + RestoreConfig defines configuration related to the workspace restore init container + that is used to restore workspace data from a backup image. + properties: + env: + description: Env allows defining additional environment variables + for the restore container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + imagePullPolicy: + description: |- + ImagePullPolicy configures the imagePullPolicy for the restore container. + If undefined, the general setting .config.workspace.imagePullPolicy is used instead. + type: string + resources: + description: |- + Resources defines the resource (cpu, memory) limits and requests for the restore + container. To explicitly not specify a limit or request, define the resource + quantity as zero ('0') + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object runtimeClassName: description: RuntimeClassName defines the spec.runtimeClassName for DevWorkspace pods created by the DevWorkspace Operator. diff --git a/deploy/deployment/openshift/combined.yaml b/deploy/deployment/openshift/combined.yaml index 5bc6c6adb..d1e695ece 100644 --- a/deploy/deployment/openshift/combined.yaml +++ b/deploy/deployment/openshift/combined.yaml @@ -4727,6 +4727,238 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + restore: + description: |- + RestoreConfig defines configuration related to the workspace restore init container + that is used to restore workspace data from a backup image. + properties: + env: + description: Env allows defining additional environment variables + for the restore container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + imagePullPolicy: + description: |- + ImagePullPolicy configures the imagePullPolicy for the restore container. + If undefined, the general setting .config.workspace.imagePullPolicy is used instead. + type: string + resources: + description: |- + Resources defines the resource (cpu, memory) limits and requests for the restore + container. To explicitly not specify a limit or request, define the resource + quantity as zero ('0') + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object runtimeClassName: description: RuntimeClassName defines the spec.runtimeClassName for DevWorkspace pods created by the DevWorkspace Operator. diff --git a/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml b/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml index dd5a8b46e..43c86f02a 100644 --- a/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml +++ b/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml @@ -4727,6 +4727,238 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + restore: + description: |- + RestoreConfig defines configuration related to the workspace restore init container + that is used to restore workspace data from a backup image. + properties: + env: + description: Env allows defining additional environment variables + for the restore container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + imagePullPolicy: + description: |- + ImagePullPolicy configures the imagePullPolicy for the restore container. + If undefined, the general setting .config.workspace.imagePullPolicy is used instead. + type: string + resources: + description: |- + Resources defines the resource (cpu, memory) limits and requests for the restore + container. To explicitly not specify a limit or request, define the resource + quantity as zero ('0') + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object runtimeClassName: description: RuntimeClassName defines the spec.runtimeClassName for DevWorkspace pods created by the DevWorkspace Operator. diff --git a/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml b/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml index 1953c1886..2c57aea74 100644 --- a/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml +++ b/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml @@ -4725,6 +4725,238 @@ spec: maxLength: 63 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + restore: + description: |- + RestoreConfig defines configuration related to the workspace restore init container + that is used to restore workspace data from a backup image. + properties: + env: + description: Env allows defining additional environment variables + for the restore container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + imagePullPolicy: + description: |- + ImagePullPolicy configures the imagePullPolicy for the restore container. + If undefined, the general setting .config.workspace.imagePullPolicy is used instead. + type: string + resources: + description: |- + Resources defines the resource (cpu, memory) limits and requests for the restore + container. To explicitly not specify a limit or request, define the resource + quantity as zero ('0') + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object runtimeClassName: description: RuntimeClassName defines the spec.runtimeClassName for DevWorkspace pods created by the DevWorkspace Operator. diff --git a/pkg/common/naming.go b/pkg/common/naming.go index 1e7cb8c7e..5d9181c76 100644 --- a/pkg/common/naming.go +++ b/pkg/common/naming.go @@ -159,6 +159,14 @@ func WorkspaceSCCRolebindingName(sccName string) string { return fmt.Sprintf("devworkspace-use-%s", sccName) } +func RegistryImagePullerRoleName() string { + return fmt.Sprintf("system:image-puller") +} + +func RegistryImagePullerRolebindingName(namespace string) string { + return fmt.Sprintf("devworkspace-registry-image-puller-%s-binding", namespace) +} + // OldWorkspaceRoleName returns the name used for the workspace serviceaccount role // // Deprecated: use WorkspaceRoleName() instead. diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index ab2f7b26a..c8862670d 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -68,6 +68,18 @@ var defaultConfig = &v1alpha1.OperatorConfiguration{ }, }, }, + RestoreConfig: &v1alpha1.RestoreConfig{ + Resources: &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + corev1.ResourceCPU: resource.MustParse("500m"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("128Mi"), + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, DefaultContainerResources: &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("128Mi"), diff --git a/pkg/config/sync.go b/pkg/config/sync.go index e65222034..d8a5e5173 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -398,6 +398,26 @@ func mergeConfig(from, to *controller.OperatorConfiguration) { to.Workspace.ProjectCloneConfig.Env = from.Workspace.ProjectCloneConfig.Env } } + if from.Workspace.RestoreConfig != nil { + if to.Workspace.RestoreConfig == nil { + to.Workspace.RestoreConfig = &controller.RestoreConfig{} + } + if from.Workspace.RestoreConfig.ImagePullPolicy != "" { + to.Workspace.RestoreConfig.ImagePullPolicy = from.Workspace.RestoreConfig.ImagePullPolicy + } + if from.Workspace.RestoreConfig.Resources != nil { + if to.Workspace.RestoreConfig.Resources == nil { + to.Workspace.RestoreConfig.Resources = &corev1.ResourceRequirements{} + } + to.Workspace.RestoreConfig.Resources = mergeResources(from.Workspace.RestoreConfig.Resources, to.Workspace.RestoreConfig.Resources) + } + + // Overwrite env instead of trying to merge, don't want to bother merging lists when + // the default is empty + if from.Workspace.RestoreConfig.Env != nil { + to.Workspace.RestoreConfig.Env = from.Workspace.RestoreConfig.Env + } + } if from.Workspace.DefaultContainerResources != nil { if to.Workspace.DefaultContainerResources == nil { to.Workspace.DefaultContainerResources = &corev1.ResourceRequirements{} diff --git a/pkg/constants/attributes.go b/pkg/constants/attributes.go index 484c7c885..c9dded218 100644 --- a/pkg/constants/attributes.go +++ b/pkg/constants/attributes.go @@ -151,4 +151,24 @@ const ( // of a cloned project. If the bootstrap process is successful, project-clone will automatically remove this attribute // from the DevWorkspace BootstrapDevWorkspaceAttribute = "controller.devfile.io/bootstrap-devworkspace" + + // WorkspaceRestoreAttribute defines whether workspace restore should be performed for a DevWorkspace. + // If this attribute is present and set to true, the restore process will be performed during + // workspace initialization, before workspace containers start. + // + // The backup source is automatically determined from the cluster configuration unless overridden + // by WorkspaceRestoreSourceImageAttribute. + WorkspaceRestoreAttribute = "controller.devfile.io/restore-workspace" + + // WorkspaceRestoreSourceImageAttribute defines the backup image source to restore from for a DevWorkspace. + // The value should be a container image reference containing a workspace backup created by the backup functionality. + // The restore will be performed during workspace initialization before the workspace containers start. + // For example: + // + // spec: + // template: + // attributes: + // controller.devfile.io/restore-source-image: "registry.example.com/backups/my-workspace:20241111-123456" + // + WorkspaceRestoreSourceImageAttribute = "controller.devfile.io/restore-source-image" ) diff --git a/pkg/library/env/workspaceenv.go b/pkg/library/env/workspaceenv.go index 6adf0ab24..dd9342b33 100644 --- a/pkg/library/env/workspaceenv.go +++ b/pkg/library/env/workspaceenv.go @@ -49,6 +49,24 @@ func AddCommonEnvironmentVariables(podAdditions *v1alpha1.PodAdditions, clusterD return nil } +func GetEnvironmentVariablesForProjectRestore(workspace *common.DevWorkspaceWithConfig) []corev1.EnvVar { + var restoreEnv []corev1.EnvVar + restoreEnv = append(restoreEnv, workspace.Config.Workspace.RestoreConfig.Env...) + restoreEnv = append(restoreEnv, commonEnvironmentVariables(workspace)...) + restoreEnv = append(restoreEnv, corev1.EnvVar{ + Name: devfileConstants.ProjectsRootEnvVar, + Value: constants.DefaultProjectsSourcesRoot, + }) + if workspace.Config.Workspace.BackupCronJob.OrasConfig != nil { + restoreEnv = append(restoreEnv, corev1.EnvVar{ + Name: "ORAS_EXTRA_ARGS", + Value: workspace.Config.Workspace.BackupCronJob.OrasConfig.ExtraArgs, + }) + } + + return restoreEnv +} + func GetEnvironmentVariablesForProjectClone(workspace *common.DevWorkspaceWithConfig) []corev1.EnvVar { var cloneEnv []corev1.EnvVar cloneEnv = append(cloneEnv, workspace.Config.Workspace.ProjectCloneConfig.Env...) diff --git a/pkg/library/projects/clone.go b/pkg/library/projects/clone.go index c1e4d9de1..f07e6fdeb 100644 --- a/pkg/library/projects/clone.go +++ b/pkg/library/projects/clone.go @@ -31,7 +31,7 @@ import ( ) const ( - projectClonerContainerName = "project-clone" + ProjectClonerContainerName = "project-clone" ) type Options struct { @@ -118,7 +118,7 @@ func GetProjectCloneInitContainer(workspace *dw.DevWorkspaceTemplateSpec, option } return &corev1.Container{ - Name: projectClonerContainerName, + Name: ProjectClonerContainerName, Image: cloneImage, Env: options.Env, Resources: *resources, diff --git a/pkg/library/restore/restore.go b/pkg/library/restore/restore.go new file mode 100644 index 000000000..8f06454f2 --- /dev/null +++ b/pkg/library/restore/restore.go @@ -0,0 +1,150 @@ +// +// Copyright (c) 2019-2026 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 restore defines library functions for restoring workspace data from backup images +package restore + +import ( + "context" + "fmt" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/common" + devfileConstants "github.com/devfile/devworkspace-operator/pkg/library/constants" + dwResources "github.com/devfile/devworkspace-operator/pkg/library/resources" + "github.com/devfile/devworkspace-operator/pkg/secrets" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/devfile/devworkspace-operator/internal/images" + "github.com/devfile/devworkspace-operator/pkg/constants" +) + +const ( + WorkspaceRestoreContainerName = "workspace-restore" +) + +type Options struct { + Image string + PullPolicy corev1.PullPolicy + Resources *corev1.ResourceRequirements + Env []corev1.EnvVar +} + +func IsWorkspaceRestoreRequested(workspace *dw.DevWorkspaceTemplateSpec) bool { + if !workspace.Attributes.Exists(constants.WorkspaceRestoreAttribute) { + return false + } + return workspace.Attributes.GetBoolean(constants.WorkspaceRestoreAttribute, nil) + +} + +// GetWorkspaceRestoreInitContainer creates an init container that restores workspace data from a backup image. +// The restore container uses the existing workspace-recovery.sh script to extract backup content. +func GetWorkspaceRestoreInitContainer( + ctx context.Context, + workspace *common.DevWorkspaceWithConfig, + k8sClient client.Client, + options Options, + scheme *runtime.Scheme, + log logr.Logger, +) (*corev1.Container, *corev1.Secret, error) { + workspaceTemplate := &workspace.Spec.Template + + // Determine the source image for restore + var err error + var restoreSourceImage string + if workspaceTemplate.Attributes.Exists(constants.WorkspaceRestoreSourceImageAttribute) { + // User choose custom image specified in the attribute + restoreSourceImage = workspaceTemplate.Attributes.GetString(constants.WorkspaceRestoreSourceImageAttribute, &err) + if err != nil { + return nil, nil, fmt.Errorf("failed to read %s attribute on workspace: %w", constants.WorkspaceRestoreSourceImageAttribute, err) + } + } else { + if workspace.Config.Workspace.BackupCronJob == nil { + return nil, nil, fmt.Errorf("workspace restore requested but backup cron job configuration is missing") + } + if workspace.Config.Workspace.BackupCronJob.Registry == nil || workspace.Config.Workspace.BackupCronJob.Registry.Path == "" { + return nil, nil, fmt.Errorf("workspace restore requested but backup cron job registry is not configured") + } + // Use default backup image location based on workspace info + restoreSourceImage = workspace.Config.Workspace.BackupCronJob.Registry.Path + "/" + workspace.Namespace + "/" + workspace.Name + ":latest" + } + if restoreSourceImage == "" { + return nil, nil, fmt.Errorf("empty value for attribute %s is invalid", constants.WorkspaceRestoreSourceImageAttribute) + } + + if !hasContainerComponents(workspaceTemplate) { + // Avoid adding restore init container when DevWorkspace does not define any containers + return nil, nil, nil + } + + // Use the project backup image which contains the workspace-recovery.sh script + restoreImage := images.GetProjectBackupImage() + + // Prepare environment variables for the restore script + env := append(options.Env, []corev1.EnvVar{ + {Name: "BACKUP_IMAGE", Value: restoreSourceImage}, + }...) + + resources := dwResources.FilterResources(options.Resources) + if err := dwResources.ValidateResources(resources); err != nil { + return nil, nil, fmt.Errorf("invalid resources for workspace restore container: %w", err) + } + volumeMounts := []corev1.VolumeMount{ + { + Name: devfileConstants.ProjectsVolumeName, + MountPath: constants.DefaultProjectsSourcesRoot, + }, + } + registryAuthSecret, err := secrets.HandleRegistryAuthSecret(ctx, k8sClient, workspace.DevWorkspace, workspace.Config, "", scheme, log) + if err != nil { + return nil, nil, fmt.Errorf("handling registry auth secret for workspace restore: %w", err) + } + if registryAuthSecret != nil { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: "registry-auth-secret", + MountPath: "/tmp/.docker", + ReadOnly: true, + }) + env = append(env, corev1.EnvVar{ + Name: "REGISTRY_AUTH_FILE", + Value: "/tmp/.docker/.dockerconfigjson", + }) + } + + restoreContainer := &corev1.Container{ + Name: WorkspaceRestoreContainerName, + Image: restoreImage, + Command: []string{"/workspace-recovery.sh"}, + Args: []string{"--restore"}, + Env: env, + Resources: *resources, + VolumeMounts: volumeMounts, + ImagePullPolicy: options.PullPolicy, + } + return restoreContainer, registryAuthSecret, nil +} + +func hasContainerComponents(workspace *dw.DevWorkspaceTemplateSpec) bool { + for _, component := range workspace.Components { + if component.Container != nil { + return true + } + } + return false +} diff --git a/pkg/library/storage/storage.go b/pkg/library/storage/storage.go index f32aecb91..1dbf9b61d 100644 --- a/pkg/library/storage/storage.go +++ b/pkg/library/storage/storage.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 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 diff --git a/pkg/provision/workspace/rbac/common_test.go b/pkg/provision/workspace/rbac/common_test.go index 878bda8c3..874f963d2 100644 --- a/pkg/provision/workspace/rbac/common_test.go +++ b/pkg/provision/workspace/rbac/common_test.go @@ -25,6 +25,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/config" "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/infrastructure" "github.com/devfile/devworkspace-operator/pkg/provision/sync" "github.com/go-logr/logr/testr" "github.com/stretchr/testify/assert" @@ -104,6 +105,7 @@ var ( ) func TestSyncRBAC(t *testing.T) { + infrastructure.InitializeForTesting(infrastructure.Kubernetes) testdw1 := getTestDevWorkspaceWithAttributes(t, "test-devworkspace", constants.WorkspaceSCCAttribute, testSCCName) testdw2 := getTestDevWorkspaceWithAttributes(t, "test-devworkspace2", constants.WorkspaceSCCAttribute, testSCCName) testdw1SAName := common.ServiceAccountName(testdw1) diff --git a/pkg/provision/workspace/rbac/rolebinding.go b/pkg/provision/workspace/rbac/rolebinding.go index a590020c5..6ea8cd7bd 100644 --- a/pkg/provision/workspace/rbac/rolebinding.go +++ b/pkg/provision/workspace/rbac/rolebinding.go @@ -19,6 +19,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/infrastructure" "github.com/devfile/devworkspace-operator/pkg/provision/sync" rbacv1 "k8s.io/api/rbac/v1" @@ -31,18 +32,33 @@ func syncRolebindings(workspace *common.DevWorkspaceWithConfig, api sync.Cluster saName := common.ServiceAccountName(workspace) defaultRoleName := common.WorkspaceRoleName() defaultRolebindingName := common.WorkspaceRolebindingName() - if err := addServiceAccountToRolebinding(saName, workspace.Namespace, defaultRoleName, defaultRolebindingName, api); err != nil { + if err := addServiceAccountToRolebinding(saName, workspace.Namespace, defaultRoleName, defaultRolebindingName, "Role", api); err != nil { return err } - if !workspace.Spec.Template.Attributes.Exists(constants.WorkspaceSCCAttribute) { - return nil + if workspace.Spec.Template.Attributes.Exists(constants.WorkspaceSCCAttribute) { + sccName := workspace.Spec.Template.Attributes.GetString(constants.WorkspaceSCCAttribute, nil) + sccRoleName := common.WorkspaceSCCRoleName(sccName) + sccRolebindingName := common.WorkspaceSCCRolebindingName(sccName) + if err := addServiceAccountToRolebinding(saName, workspace.Namespace, sccRoleName, sccRolebindingName, "Role", api); err != nil { + return err + } } - sccName := workspace.Spec.Template.Attributes.GetString(constants.WorkspaceSCCAttribute, nil) - sccRoleName := common.WorkspaceSCCRoleName(sccName) - sccRolebindingName := common.WorkspaceSCCRolebindingName(sccName) - if err := addServiceAccountToRolebinding(saName, workspace.Namespace, sccRoleName, sccRolebindingName, api); err != nil { - return err + if infrastructure.IsOpenShift() { + // On OpenShift, add the workspace ServiceAccount to the cluster rolebinding that allows pulling images from + // the internal registry + registryRoleName := common.RegistryImagePullerRoleName() + registryRolebindingName := common.RegistryImagePullerRolebindingName(workspace.Namespace) + if err := addServiceAccountToRolebinding( + saName, + workspace.Namespace, + registryRoleName, + registryRolebindingName, + "ClusterRole", + api); err != nil { + return err + } } + return nil } @@ -67,7 +83,7 @@ func deleteRolebinding(name, namespace string, api sync.ClusterAPI) error { } } -func addServiceAccountToRolebinding(saName, namespace, roleName, rolebindingName string, api sync.ClusterAPI) error { +func addServiceAccountToRolebinding(saName, namespace, roleName, rolebindingName string, roleKind string, api sync.ClusterAPI) error { rolebinding := &rbacv1.RoleBinding{} namespacedName := types.NamespacedName{ Name: rolebindingName, @@ -80,7 +96,7 @@ func addServiceAccountToRolebinding(saName, namespace, roleName, rolebindingName break case k8sErrors.IsNotFound(err): // Rolebinding not created yet, initiailize default rolebinding and add SA to it - rolebinding = generateDefaultRolebinding(rolebindingName, namespace, roleName) + rolebinding = generateDefaultRolebinding(rolebindingName, namespace, roleName, roleKind) default: return err } @@ -132,7 +148,7 @@ func removeServiceAccountFromRolebinding(saName, namespace, roleBindingName stri return nil } -func generateDefaultRolebinding(name, namespace, roleName string) *rbacv1.RoleBinding { +func generateDefaultRolebinding(name, namespace, roleName string, roleKind string) *rbacv1.RoleBinding { return &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -140,7 +156,7 @@ func generateDefaultRolebinding(name, namespace, roleName string) *rbacv1.RoleBi Labels: rbacLabels, }, RoleRef: rbacv1.RoleRef{ - Kind: "Role", + Kind: roleKind, Name: roleName, }, // Subjects added for each workspace ServiceAccount diff --git a/pkg/provision/workspace/rbac/rolebinding_test.go b/pkg/provision/workspace/rbac/rolebinding_test.go index e0549e3e9..ae0cbb9a1 100644 --- a/pkg/provision/workspace/rbac/rolebinding_test.go +++ b/pkg/provision/workspace/rbac/rolebinding_test.go @@ -93,6 +93,10 @@ func TestCreatesSCCRolebindingIfNotExists(t *testing.T) { assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") } err = syncRolebindings(testdw, api) + if assert.Error(t, err, "Should return RetryError to indicate that registry rolebinding was created") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + err = syncRolebindings(testdw, api) assert.NoError(t, err, "Should not return error if rolebindings are in sync") actualRB := &rbacv1.RoleBinding{} err = api.Client.Get(api.Ctx, types.NamespacedName{ @@ -120,13 +124,21 @@ func TestAddsMultipleSubjectsToSCCRolebinding(t *testing.T) { assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") } err = syncRolebindings(testdw, api) + if assert.Error(t, err, "Should return RetryError to indicate that registry rolebinding was created") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + err = syncRolebindings(testdw, api) assert.NoError(t, err, "Should not return error if rolebindings are in sync") err = syncRolebindings(testdw2, api) - if assert.Error(t, err, "Should return RetryError to indicate that default rolebinding was created") { + if assert.Error(t, err, "Should return RetryError to indicate that default rolebinding was updated") { assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") } err = syncRolebindings(testdw2, api) - if assert.Error(t, err, "Should return RetryError to indicate that SCC rolebinding was created") { + if assert.Error(t, err, "Should return RetryError to indicate that SCC rolebinding was updated") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + err = syncRolebindings(testdw2, api) + if assert.Error(t, err, "Should return RetryError to indicate that registry rolebinding was updated") { assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") } err = syncRolebindings(testdw2, api) @@ -145,6 +157,70 @@ func TestAddsMultipleSubjectsToSCCRolebinding(t *testing.T) { assert.True(t, testHasSubject(expectedSAName2, testNamespace, actualRB), "Created SCC rolebinding should have both workspace SAs as subjects") } +func TestCreatesRegistryImagePullerRolebindingOnOpenShift(t *testing.T) { + infrastructure.InitializeForTesting(infrastructure.OpenShiftv4) + testdw := getTestDevWorkspace("test-devworkspace") + api := getTestClusterAPI(t, testdw.DevWorkspace) + retryErr := &dwerrors.RetryError{} + + // First call should create default rolebinding + err := syncRolebindings(testdw, api) + if assert.Error(t, err, "Should return RetryError to indicate that default rolebinding was created") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + + // Second call should create registry image puller rolebinding + err = syncRolebindings(testdw, api) + if assert.Error(t, err, "Should return RetryError to indicate that registry rolebinding was created") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + + // Third call should succeed with no errors + err = syncRolebindings(testdw, api) + assert.NoError(t, err, "Should not return error if rolebindings are in sync") + + // Verify the registry image puller rolebinding was created correctly + actualRB := &rbacv1.RoleBinding{} + err = api.Client.Get(api.Ctx, types.NamespacedName{ + Name: common.RegistryImagePullerRolebindingName(testNamespace), + Namespace: testNamespace, + }, actualRB) + assert.NoError(t, err, "Registry image puller rolebinding should be created") + + // Verify it references the correct ClusterRole + assert.Equal(t, "ClusterRole", actualRB.RoleRef.Kind, "Rolebinding should reference ClusterRole") + assert.Equal(t, common.RegistryImagePullerRoleName(), actualRB.RoleRef.Name, "Rolebinding should reference system:image-puller ClusterRole") + + // Verify it has the workspace service account as subject + expectedSAName := common.ServiceAccountName(testdw) + assert.True(t, testHasSubject(expectedSAName, testNamespace, actualRB), "Registry rolebinding should have workspace SA as subject") +} + +func TestDoesNotCreateRegistryImagePullerRolebindingOnKubernetes(t *testing.T) { + infrastructure.InitializeForTesting(infrastructure.Kubernetes) + testdw := getTestDevWorkspace("test-devworkspace") + api := getTestClusterAPI(t, testdw.DevWorkspace) + retryErr := &dwerrors.RetryError{} + + // First call should create default rolebinding + err := syncRolebindings(testdw, api) + if assert.Error(t, err, "Should return RetryError to indicate that default rolebinding was created") { + assert.ErrorAs(t, err, &retryErr, "Error should have RetryError type") + } + + // Second call should succeed with no errors (no registry rolebinding on Kubernetes) + err = syncRolebindings(testdw, api) + assert.NoError(t, err, "Should not return error if rolebindings are in sync") + + // Verify the registry image puller rolebinding was NOT created + actualRB := &rbacv1.RoleBinding{} + err = api.Client.Get(api.Ctx, types.NamespacedName{ + Name: common.RegistryImagePullerRolebindingName(testNamespace), + Namespace: testNamespace, + }, actualRB) + assert.Error(t, err, "Registry image puller rolebinding should NOT be created on Kubernetes") +} + func testHasSubject(subjName, namespace string, rolebinding *rbacv1.RoleBinding) bool { for _, subject := range rolebinding.Subjects { if subject.Name == subjName && subject.Namespace == namespace && subject.Kind == rbacv1.ServiceAccountKind { diff --git a/pkg/secrets/backup.go b/pkg/secrets/backup.go new file mode 100644 index 000000000..486beff9f --- /dev/null +++ b/pkg/secrets/backup.go @@ -0,0 +1,107 @@ +// +// Copyright (c) 2019-2026 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 secrets + +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/constants" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func HandleRegistryAuthSecret(ctx context.Context, c client.Client, workspace *dw.DevWorkspace, + dwOperatorConfig *controllerv1alpha1.OperatorConfiguration, operatorConfigNamespace string, scheme *runtime.Scheme, log logr.Logger, +) (*corev1.Secret, error) { + secretName := dwOperatorConfig.Workspace.BackupCronJob.Registry.AuthSecret + if secretName == "" { + // No auth secret configured - anonymous access to registry + return nil, nil + } + + // First check the workspace namespace for the secret + registryAuthSecret := &corev1.Secret{} + err := c.Get(ctx, client.ObjectKey{ + Name: secretName, + Namespace: workspace.Namespace}, registryAuthSecret) + if err == nil { + log.Info("Successfully retrieved registry auth secret for backup from workspace namespace", "secretName", secretName) + return registryAuthSecret, nil + } + if client.IgnoreNotFound(err) != nil { + return nil, err + } + // If we don't provide an operator namespace, don't attempt to look there + if operatorConfigNamespace == "" { + return nil, nil + } + log.Info("Registry auth secret not found in workspace namespace, checking operator namespace", "secretName", secretName) + + // If the secret is not found in the workspace namespace, check the operator namespace as fallback + err = c.Get(ctx, client.ObjectKey{ + Name: secretName, + Namespace: operatorConfigNamespace}, registryAuthSecret) + if err != nil { + log.Error(err, "Failed to get registry auth secret for backup job", "secretName", secretName) + return nil, err + } + log.Info("Successfully retrieved registry auth secret for backup job", "secretName", secretName) + return CopySecret(ctx, c, workspace, registryAuthSecret, scheme, log) +} + +// CopySecret copies the given secret from the operator namespace to the workspace namespace. +func CopySecret(ctx context.Context, c client.Client, workspace *dw.DevWorkspace, sourceSecret *corev1.Secret, scheme *runtime.Scheme, log logr.Logger) (namespaceSecret *corev1.Secret, err error) { + existingNamespaceSecret := &corev1.Secret{} + err = c.Get(ctx, client.ObjectKey{ + Name: constants.DevWorkspaceBackupAuthSecretName, + Namespace: workspace.Namespace}, existingNamespaceSecret) + if client.IgnoreNotFound(err) != nil { + log.Error(err, "Failed to check for existing registry auth secret in workspace namespace", "namespace", workspace.Namespace) + return nil, err + } + if err == nil { + err = c.Delete(ctx, existingNamespaceSecret) + if err != nil { + return nil, err + } + } + namespaceSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.DevWorkspaceBackupAuthSecretName, + Namespace: workspace.Namespace, + Labels: map[string]string{ + constants.DevWorkspaceIDLabel: workspace.Status.DevWorkspaceId, + constants.DevWorkspaceWatchSecretLabel: "true", + }, + }, + Data: sourceSecret.Data, + Type: sourceSecret.Type, + } + if err := controllerutil.SetControllerReference(workspace, namespaceSecret, scheme); err != nil { + return nil, err + } + err = c.Create(ctx, namespaceSecret) + if err == nil { + log.Info("Successfully created secret", "name", namespaceSecret.Name, "namespace", workspace.Namespace) + } + return namespaceSecret, err +} diff --git a/project-backup/workspace-recovery.sh b/project-backup/workspace-recovery.sh index 440176f62..caa881fa4 100644 --- a/project-backup/workspace-recovery.sh +++ b/project-backup/workspace-recovery.sh @@ -16,39 +16,22 @@ set -euo pipefail -# --- Configuration --- -: "${DEVWORKSPACE_BACKUP_REGISTRY:?Missing DEVWORKSPACE_BACKUP_REGISTRY}" -: "${DEVWORKSPACE_NAMESPACE:?Missing DEVWORKSPACE_NAMESPACE}" -: "${DEVWORKSPACE_NAME:?Missing DEVWORKSPACE_NAME}" -: "${BACKUP_SOURCE_PATH:?Missing BACKUP_SOURCE_PATH}" - -BACKUP_IMAGE="${DEVWORKSPACE_BACKUP_REGISTRY}/${DEVWORKSPACE_NAMESPACE}/${DEVWORKSPACE_NAME}:latest" - # --- Functions --- -backup() { - TARBALL_NAME="devworkspace-backup.tar.gz" - cd /tmp - echo "Backing up devworkspace '$DEVWORKSPACE_NAME' in namespace '$DEVWORKSPACE_NAMESPACE' to image '$BACKUP_IMAGE'" - # Create tarball of the backup source path - tar -czvf "$TARBALL_NAME" -C "$BACKUP_SOURCE_PATH" . +# Setup registry authentication and return auth arguments for oras +# Args: $1 - registry image (e.g., "registry.example.com/namespace/image:tag") +# Returns: Sets ORAS_AUTH_ARGS array with authentication arguments +setup_registry_auth() { + local image="$1" + ORAS_AUTH_ARGS=() - # Push the tarball to the OCI registry using oras as a custom artifact - oras_args=( - push - "$BACKUP_IMAGE" - --artifact-type application/vnd.devworkspace.backup.artifact.v1+json - --annotation devworkspace.name="$DEVWORKSPACE_NAME" - --annotation devworkspace.namespace="$DEVWORKSPACE_NAMESPACE" - --disable-path-validation - ) if [[ -n "${REGISTRY_AUTH_FILE:-}" ]]; then # If REGISTRY_AUTH_FILE is provided, use it for authentication - oras_args+=(--registry-config "$REGISTRY_AUTH_FILE") + ORAS_AUTH_ARGS+=(--registry-config "$REGISTRY_AUTH_FILE") elif [[ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]]; then echo "Using mounted service account token for registry authentication" TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - REGISTRY_HOST=$(echo "$BACKUP_IMAGE" | cut -d'/' -f1) + REGISTRY_HOST=$(echo "$image" | cut -d'/' -f1) # Create temporary auth config for oras REGISTRY_AUTH_FILE="/tmp/registry_auth.json" @@ -57,24 +40,57 @@ backup() { if [[ "$REGISTRY_HOST" == *"openshift"* ]] || [[ "$REGISTRY_HOST" == *"svc.cluster.local"* ]]; then # OpenShift internal registry authentication # Use the service account CA for TLS verification + ORAS_LOGIN_ARGS=( + login + --password-stdin + -u serviceaccount + --registry-config "$REGISTRY_AUTH_FILE" + + ) + if [[ -n "${ORAS_EXTRA_ARGS:-}" ]]; then + extra_args=( ${ORAS_EXTRA_ARGS} ) + ORAS_LOGIN_ARGS+=("${extra_args[@]}") + fi + if [[ -f /var/run/secrets/kubernetes.io/serviceaccount/ca.crt ]]; then - oras login --password-stdin \ - --ca-file /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ - -u serviceaccount \ - --registry-config "$REGISTRY_AUTH_FILE" \ - "$REGISTRY_HOST" <<< "$TOKEN" + ORAS_LOGIN_ARGS+=(--ca-file /var/run/secrets/kubernetes.io/serviceaccount/ca.crt) else - # Fallback to insecure if CA cert is not available - oras login --password-stdin \ - --insecure \ - -u serviceaccount \ - --registry-config "$REGISTRY_AUTH_FILE" \ - "$REGISTRY_HOST" <<< "$TOKEN" + ORAS_LOGIN_ARGS+=(--insecure) fi + + oras "${ORAS_LOGIN_ARGS[@]}" "$REGISTRY_HOST" <<< "$TOKEN" fi - oras_args+=(--registry-config "$REGISTRY_AUTH_FILE") + ORAS_AUTH_ARGS+=(--registry-config "$REGISTRY_AUTH_FILE") fi +} + +backup() { + : "${BACKUP_SOURCE_PATH:?Missing BACKUP_SOURCE_PATH}" + : "${DEVWORKSPACE_BACKUP_REGISTRY:?Missing DEVWORKSPACE_BACKUP_REGISTRY}" + : "${DEVWORKSPACE_NAMESPACE:?Missing DEVWORKSPACE_NAMESPACE}" + : "${DEVWORKSPACE_NAME:?Missing DEVWORKSPACE_NAME}" + BACKUP_IMAGE="${DEVWORKSPACE_BACKUP_REGISTRY}/${DEVWORKSPACE_NAMESPACE}/${DEVWORKSPACE_NAME}:latest" + TARBALL_NAME="devworkspace-backup.tar.gz" + cd /tmp + echo "Backing up devworkspace '$DEVWORKSPACE_NAME' in namespace '$DEVWORKSPACE_NAMESPACE' to image '$BACKUP_IMAGE'" + + # Create tarball of the backup source path + tar -czvf "$TARBALL_NAME" -C "$BACKUP_SOURCE_PATH" . + + # Push the tarball to the OCI registry using oras as a custom artifact + oras_args=( + push + "$BACKUP_IMAGE" + --artifact-type application/vnd.devworkspace.backup.artifact.v1+json + --annotation devworkspace.name="$DEVWORKSPACE_NAME" + --annotation devworkspace.namespace="$DEVWORKSPACE_NAMESPACE" + --disable-path-validation + ) + + # Setup registry authentication + setup_registry_auth "$BACKUP_IMAGE" + oras_args+=("${ORAS_AUTH_ARGS[@]}") if [[ -n "${ORAS_EXTRA_ARGS:-}" ]]; then extra_args=( ${ORAS_EXTRA_ARGS} ) oras_args+=("${extra_args[@]}") @@ -92,12 +108,42 @@ backup() { } restore() { - local container_name="workspace-restore" + : "${BACKUP_IMAGE:?Missing BACKUP_IMAGE}" + : "${PROJECTS_ROOT:?Missing PROJECTS_ROOT}" + + echo "Restoring devworkspace from image '$BACKUP_IMAGE' to path '$PROJECTS_ROOT'" + oras_args=( + pull + "$BACKUP_IMAGE" + --output /tmp + ) + + # Check if $PROJECTS_ROOT is empty and exit if not + if [[ -n "$(ls -A "$PROJECTS_ROOT")" ]]; then + echo "PROJECTS_ROOT '$PROJECTS_ROOT' is not empty. Skipping restore action." + exit 0 + fi + + # Setup registry authentication + setup_registry_auth "$BACKUP_IMAGE" + oras_args+=("${ORAS_AUTH_ARGS[@]}") + + if [[ -n "${ORAS_EXTRA_ARGS:-}" ]]; then + extra_args=( ${ORAS_EXTRA_ARGS} ) + oras_args+=("${extra_args[@]}") + fi + + # Pull the backup tarball from the OCI registry using oras and extract it + oras "${oras_args[@]}" + mkdir /tmp/extracted-backup + tar -xzvf /tmp/devworkspace-backup.tar.gz -C /tmp/extracted-backup + + cp -r /tmp/extracted-backup/* "$PROJECTS_ROOT" + + rm -f /tmp/devworkspace-backup.tar.gz + rm -rf /tmp/extracted-backup - podman create --name "$container_name" "$BACKUP_IMAGE" - rm -rf "${BACKUP_SOURCE_PATH:?}"/* - podman cp "$container_name":/. "$BACKUP_SOURCE_PATH" - podman rm "$container_name" + echo "Restore completed successfully." } usage() {