diff --git a/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go b/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go index 31028fd30..4d0303630 100644 --- a/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go +++ b/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go @@ -135,6 +135,9 @@ type WorkspaceConfig struct { // DefaultStorageSize defines an optional struct with fields to specify the sizes of Persistent Volume Claims for storage // classes used by DevWorkspaces. DefaultStorageSize *StorageSizes `json:"defaultStorageSize,omitempty"` + // StorageAccessMode are the desired access modes the volume should have. It defaults + // to ReadWriteOnce if not specified + StorageAccessMode []corev1.PersistentVolumeAccessMode `json:"storageAccessMode,omitempty"` // PersistUserHome defines configuration options for persisting the `/home/user/` // directory in workspaces. PersistUserHome *PersistentHomeConfig `json:"persistUserHome,omitempty"` diff --git a/apis/controller/v1alpha1/zz_generated.deepcopy.go b/apis/controller/v1alpha1/zz_generated.deepcopy.go index f4e3367d1..b04904319 100644 --- a/apis/controller/v1alpha1/zz_generated.deepcopy.go +++ b/apis/controller/v1alpha1/zz_generated.deepcopy.go @@ -731,6 +731,11 @@ func (in *WorkspaceConfig) DeepCopyInto(out *WorkspaceConfig) { *out = new(StorageSizes) (*in).DeepCopyInto(*out) } + if in.StorageAccessMode != nil { + in, out := &in.StorageAccessMode, &out.StorageAccessMode + *out = make([]v1.PersistentVolumeAccessMode, len(*in)) + copy(*out, *in) + } if in.PersistUserHome != nil { in, out := &in.PersistUserHome, &out.PersistUserHome *out = new(PersistentHomeConfig) diff --git a/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml b/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml index 114d477af..82091e27e 100644 --- a/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml +++ b/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml @@ -2988,6 +2988,13 @@ spec: type: object type: array type: object + storageAccessMode: + description: |- + StorageAccessMode are the desired access modes the volume should have. It defaults + to ReadWriteOnce if not specified + items: + type: string + type: array storageClassName: description: |- StorageClassName defines an optional storageClass to use for persistent diff --git a/deploy/deployment/kubernetes/combined.yaml b/deploy/deployment/kubernetes/combined.yaml index d47eba2e4..c7dd78c79 100644 --- a/deploy/deployment/kubernetes/combined.yaml +++ b/deploy/deployment/kubernetes/combined.yaml @@ -3139,6 +3139,13 @@ spec: type: object type: array type: object + storageAccessMode: + description: |- + StorageAccessMode are the desired access modes the volume should have. It defaults + to ReadWriteOnce if not specified + items: + type: string + type: array storageClassName: description: |- StorageClassName defines an optional storageClass to use for persistent 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 506c2bcea..ddb343d36 100644 --- a/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml +++ b/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml @@ -3139,6 +3139,13 @@ spec: type: object type: array type: object + storageAccessMode: + description: |- + StorageAccessMode are the desired access modes the volume should have. It defaults + to ReadWriteOnce if not specified + items: + type: string + type: array storageClassName: description: |- StorageClassName defines an optional storageClass to use for persistent diff --git a/deploy/deployment/openshift/combined.yaml b/deploy/deployment/openshift/combined.yaml index 77ce338b3..8601fa4ea 100644 --- a/deploy/deployment/openshift/combined.yaml +++ b/deploy/deployment/openshift/combined.yaml @@ -3139,6 +3139,13 @@ spec: type: object type: array type: object + storageAccessMode: + description: |- + StorageAccessMode are the desired access modes the volume should have. It defaults + to ReadWriteOnce if not specified + items: + type: string + type: array storageClassName: description: |- StorageClassName defines an optional storageClass to use for persistent 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 506c2bcea..ddb343d36 100644 --- a/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml +++ b/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml @@ -3139,6 +3139,13 @@ spec: type: object type: array type: object + storageAccessMode: + description: |- + StorageAccessMode are the desired access modes the volume should have. It defaults + to ReadWriteOnce if not specified + items: + type: string + type: array storageClassName: description: |- StorageClassName defines an optional storageClass to use for persistent diff --git a/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml b/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml index 3a0c66fc5..f1ab6e067 100644 --- a/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml +++ b/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml @@ -3137,6 +3137,13 @@ spec: type: object type: array type: object + storageAccessMode: + description: |- + StorageAccessMode are the desired access modes the volume should have. It defaults + to ReadWriteOnce if not specified + items: + type: string + type: array storageClassName: description: |- StorageClassName defines an optional storageClass to use for persistent diff --git a/pkg/config/sync.go b/pkg/config/sync.go index 497f53a68..c5b8150f7 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -341,6 +341,9 @@ func mergeConfig(from, to *controller.OperatorConfiguration) { if from.Workspace.ContainerSecurityContext != nil { to.Workspace.ContainerSecurityContext = mergeContainerSecurityContext(to.Workspace.ContainerSecurityContext, from.Workspace.ContainerSecurityContext) } + if from.Workspace.StorageAccessMode != nil { + to.Workspace.StorageAccessMode = from.Workspace.StorageAccessMode + } if from.Workspace.DefaultStorageSize != nil { if to.Workspace.DefaultStorageSize == nil { to.Workspace.DefaultStorageSize = &controller.StorageSizes{} diff --git a/pkg/config/sync_test.go b/pkg/config/sync_test.go index 2c522e9cb..dbebf94c0 100644 --- a/pkg/config/sync_test.go +++ b/pkg/config/sync_test.go @@ -440,6 +440,24 @@ func TestMergeConfigLooksAtAllFields(t *testing.T) { assert.Equal(t, expectedConfig, actualConfig, "merging configs should merge all fields") } +func TestMergeConfigMergesStorageAccessMode(t *testing.T) { + // Given + expectedConfig := &v1alpha1.OperatorConfiguration{ + Workspace: &v1alpha1.WorkspaceConfig{ + StorageAccessMode: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteMany, + }, + }, + } + actualConfig := &v1alpha1.OperatorConfiguration{} + + // When + mergeConfig(expectedConfig, actualConfig) + + // Then + assert.Equal(t, expectedConfig.Workspace.StorageAccessMode, actualConfig.Workspace.StorageAccessMode) +} + func fuzzQuantity(q *resource.Quantity, c fuzz.Continue) { q.Set(c.Int63n(999)) q.Format = resource.DecimalSI diff --git a/pkg/provision/storage/commonStorage_test.go b/pkg/provision/storage/commonStorage_test.go index 743375db0..f0f2f14f6 100644 --- a/pkg/provision/storage/commonStorage_test.go +++ b/pkg/provision/storage/commonStorage_test.go @@ -130,13 +130,13 @@ func TestUseCommonStorageProvisionerForPerUserStorageClass(t *testing.T) { func TestProvisionStorageForCommonStorageClass(t *testing.T) { tests := loadAllTestCasesOrPanic(t, "testdata/common-storage") commonStorage := CommonStorageProvisioner{} - commonPVC, err := getPVCSpec("claim-devworkspace", "test-namespace", nil, resource.MustParse("10Gi")) + commonPVC, err := getPVCSpec("claim-devworkspace", "test-namespace", nil, resource.MustParse("10Gi"), nil) if err != nil { t.Fatalf("Failure during setup: %s", err) } commonPVC.Status.Phase = corev1.ClaimBound clusterAPI := sync.ClusterAPI{ - Client: fake.NewFakeClientWithScheme(scheme, commonPVC), + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(commonPVC).Build(), Logger: zap.New(), } @@ -166,7 +166,7 @@ func TestProvisionStorageForCommonStorageClass(t *testing.T) { func TestTerminatingPVC(t *testing.T) { commonStorage := CommonStorageProvisioner{} - commonPVC, err := getPVCSpec("claim-devworkspace", "test-namespace", nil, resource.MustParse("10Gi")) + commonPVC, err := getPVCSpec("claim-devworkspace", "test-namespace", nil, resource.MustParse("10Gi"), nil) if err != nil { t.Fatalf("Failure during setup: %s", err) } @@ -174,7 +174,7 @@ func TestTerminatingPVC(t *testing.T) { commonPVC.SetDeletionTimestamp(&testTime) clusterAPI := sync.ClusterAPI{ - Client: fake.NewFakeClientWithScheme(scheme, commonPVC), + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(commonPVC).Build(), Logger: zap.New(), } testCase := loadTestCaseOrPanic(t, "testdata/common-storage/rewrites-volumes-for-common-pvc-strategy.yaml") diff --git a/pkg/provision/storage/perWorkspaceStorage.go b/pkg/provision/storage/perWorkspaceStorage.go index 8379b2086..c1a6ca523 100644 --- a/pkg/provision/storage/perWorkspaceStorage.go +++ b/pkg/provision/storage/perWorkspaceStorage.go @@ -219,7 +219,7 @@ func syncPerWorkspacePVC(workspace *common.DevWorkspaceWithConfig, clusterAPI sy } storageClass := workspace.Config.Workspace.StorageClassName - pvc, err := getPVCSpec(common.PerWorkspacePVCName(workspace.Status.DevWorkspaceId), workspace.Namespace, storageClass, *pvcSize) + pvc, err := getPVCSpec(common.PerWorkspacePVCName(workspace.Status.DevWorkspaceId), workspace.Namespace, storageClass, *pvcSize, workspace.Config.Workspace.StorageAccessMode) if err != nil { return nil, err } diff --git a/pkg/provision/storage/shared.go b/pkg/provision/storage/shared.go index e2c76dced..068734181 100644 --- a/pkg/provision/storage/shared.go +++ b/pkg/provision/storage/shared.go @@ -60,7 +60,10 @@ func WorkspaceNeedsStorage(workspace *dw.DevWorkspaceTemplateSpec) bool { return containerlib.AnyMountSources(workspace.Components) } -func getPVCSpec(name, namespace string, storageClass *string, size resource.Quantity) (*corev1.PersistentVolumeClaim, error) { +func getPVCSpec(name, namespace string, storageClass *string, size resource.Quantity, storageAccessMode []corev1.PersistentVolumeAccessMode) (*corev1.PersistentVolumeClaim, error) { + if storageAccessMode == nil || len(storageAccessMode) == 0 { + storageAccessMode = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} + } return &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ @@ -68,9 +71,7 @@ func getPVCSpec(name, namespace string, storageClass *string, size resource.Quan Namespace: namespace, }, Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{ - corev1.ReadWriteOnce, - }, + AccessModes: storageAccessMode, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ "storage": size, @@ -99,7 +100,7 @@ func syncCommonPVC(namespace string, config *v1alpha1.OperatorConfiguration, clu } } - pvc, err := getPVCSpec(config.Workspace.PVCName, namespace, config.Workspace.StorageClassName, pvcSize) + pvc, err := getPVCSpec(config.Workspace.PVCName, namespace, config.Workspace.StorageClassName, pvcSize, config.Workspace.StorageAccessMode) if err != nil { return nil, err } diff --git a/pkg/provision/storage/shared_test.go b/pkg/provision/storage/shared_test.go new file mode 100644 index 000000000..e426bc5ab --- /dev/null +++ b/pkg/provision/storage/shared_test.go @@ -0,0 +1,61 @@ +// +// Copyright (c) 2019-2025 Red Hat, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestGetPVCSpecWithDefaultStorageAccessMode(t *testing.T) { + // Given + name := "test-pvc" + namespace := "default" + storageClass := "standard" + + // When + pvc, err := getPVCSpec(name, namespace, &storageClass, resource.MustParse("5Gi"), nil) + + // Then + assert.NoError(t, err, "Expected no error") + assert.Equal(t, name, pvc.Name, "PVC name should match") + assert.Equal(t, namespace, pvc.Namespace, "PVC namespace should match") + assert.Equal(t, storageClass, *pvc.Spec.StorageClassName, "Storage class should match") + assert.Equal(t, []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, pvc.Spec.AccessModes, "Access modes should match") + assert.Equal(t, "5Gi", pvc.Spec.Resources.Requests.Storage().String(), "Storage size should match") +} + +func TestGetPVCSpecWithCustomStorageAccessMode(t *testing.T) { + // Given + name := "test-pvc" + namespace := "default" + storageClass := "standard" + storageAccessMode := []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOncePod} + + // When + pvc, err := getPVCSpec(name, namespace, &storageClass, resource.MustParse("5Gi"), storageAccessMode) + + // Then + assert.NoError(t, err, "Expected no error") + assert.Equal(t, name, pvc.Name, "PVC name should match") + assert.Equal(t, namespace, pvc.Namespace, "PVC namespace should match") + assert.Equal(t, storageClass, *pvc.Spec.StorageClassName, "Storage class should match") + assert.Equal(t, []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOncePod}, pvc.Spec.AccessModes, "Access modes should match") + assert.Equal(t, "5Gi", pvc.Spec.Resources.Requests.Storage().String(), "Storage size should match") +}