From e6aeba9e5b2122778a72b22fb8310e24637a1cc3 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Mon, 23 Feb 2026 16:23:10 +0100 Subject: [PATCH 1/3] feat: Mounting PVC into Workspace within Subpath Signed-off-by: Anatolii Bazko --- pkg/constants/metadata.go | 8 ++++- pkg/provision/automount/common_test.go | 14 +++++---- pkg/provision/automount/pvcs.go | 4 ++- .../testProvisionsPVCWithSubPath.yaml | 30 +++++++++++++++++++ .../testProvisionsPVCWithoutSubPath.yaml | 28 +++++++++++++++++ 5 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 pkg/provision/automount/testdata/testProvisionsPVCWithSubPath.yaml create mode 100644 pkg/provision/automount/testdata/testProvisionsPVCWithoutSubPath.yaml diff --git a/pkg/constants/metadata.go b/pkg/constants/metadata.go index b9664e3e1..1ee607be7 100644 --- a/pkg/constants/metadata.go +++ b/pkg/constants/metadata.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 @@ -121,6 +121,12 @@ const ( // read-write. Automounted configmaps and secrets are always mounted read-only and this annotation is ignored. DevWorkspaceMountReadyOnlyAnnotation = "controller.devfile.io/read-only" + // DevWorkspaceMountSubPathAnnotation is an annotation to configure a subPath for a mounted PVC volume. + // When set on a PVC with the automount label, the volume mount will use the specified subPath, + // allowing a subdirectory within the PVC to be mounted instead of the root. + // This annotation is only used for PersistentVolumeClaims. + DevWorkspaceMountSubPathAnnotation = "controller.devfile.io/mount-sub-path" + // DevWorkspaceRestrictedAccessAnnotation marks the intention that devworkspace access is restricted to only the creator; setting this // annotation will cause devworkspace start to fail if webhooks are disabled. // Operator also propagates it to the devworkspace-related objects to perform authorization. diff --git a/pkg/provision/automount/common_test.go b/pkg/provision/automount/common_test.go index 060c3ac79..df046c9be 100644 --- a/pkg/provision/automount/common_test.go +++ b/pkg/provision/automount/common_test.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 @@ -51,10 +51,11 @@ const ( type testCase struct { Name string `json:"name"` Input struct { - // Secrets and Configmaps are necessary for deserialization from a testcase - Secrets []corev1.Secret `json:"secrets"` - ConfigMaps []corev1.ConfigMap `json:"configmaps"` - // allObjects contains all Secrets and Configmaps defined above, for convenience + // Secrets, Configmaps, and PVCs are necessary for deserialization from a testcase + Secrets []corev1.Secret `json:"secrets"` + ConfigMaps []corev1.ConfigMap `json:"configmaps"` + PVCs []corev1.PersistentVolumeClaim `json:"pvcs"` + // allObjects contains all Secrets, Configmaps, and PVCs defined above, for convenience allObjects []client.Object } `json:"input"` Output struct { @@ -382,6 +383,9 @@ func loadTestCaseOrPanic(t *testing.T, testPath string) testCase { for idx := range test.Input.Secrets { test.Input.allObjects = append(test.Input.allObjects, &test.Input.Secrets[idx]) } + for idx := range test.Input.PVCs { + test.Input.allObjects = append(test.Input.allObjects, &test.Input.PVCs[idx]) + } // Overwrite namespace for convenience for _, obj := range test.Input.allObjects { diff --git a/pkg/provision/automount/pvcs.go b/pkg/provision/automount/pvcs.go index 653038b3d..83dbc82b0 100644 --- a/pkg/provision/automount/pvcs.go +++ b/pkg/provision/automount/pvcs.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 @@ -41,6 +41,7 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) var volumeMounts []corev1.VolumeMount for _, pvc := range pvcs.Items { mountPath := pvc.Annotations[constants.DevWorkspaceMountPathAnnotation] + subPath := pvc.Annotations[constants.DevWorkspaceMountSubPathAnnotation] if mountPath == "" { mountPath = path.Join("/tmp/", pvc.Name) } @@ -62,6 +63,7 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) volumeMounts = append(volumeMounts, corev1.VolumeMount{ Name: common.AutoMountPVCVolumeName(pvc.Name), MountPath: mountPath, + SubPath: subPath, }) } return &Resources{ diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithSubPath.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithSubPath.yaml new file mode 100644 index 000000000..63164684d --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithSubPath.yaml @@ -0,0 +1,30 @@ +name: Provisions automount PVC with subPath + +input: + pvcs: + - + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: test-pvc + labels: + controller.devfile.io/mount-to-devworkspace: "true" + annotations: + controller.devfile.io/mount-path: /data + controller.devfile.io/mount-sub-path: my/subdirectory + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + volumes: + - name: test-pvc + persistentVolumeClaim: + claimName: test-pvc + volumeMounts: + - name: test-pvc + mountPath: /data + subPath: my/subdirectory diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithoutSubPath.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithoutSubPath.yaml new file mode 100644 index 000000000..5e1307ab1 --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithoutSubPath.yaml @@ -0,0 +1,28 @@ +name: Provisions automount PVC without subPath + +input: + pvcs: + - + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: test-pvc + labels: + controller.devfile.io/mount-to-devworkspace: "true" + annotations: + controller.devfile.io/mount-path: /data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + volumes: + - name: test-pvc + persistentVolumeClaim: + claimName: test-pvc + volumeMounts: + - name: test-pvc + mountPath: /data From c33864620352715663ee5584b744a14d81df1493 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Tue, 24 Feb 2026 10:41:57 +0100 Subject: [PATCH 2/3] Update doc Signed-off-by: Anatolii Bazko --- docs/additional-configuration.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/additional-configuration.adoc b/docs/additional-configuration.adoc index 8418fd4cb..b0c994193 100644 --- a/docs/additional-configuration.adoc +++ b/docs/additional-configuration.adoc @@ -141,6 +141,7 @@ By default, resources will be mounted based on the resource name: Mounting resources can be additionally configured via **annotations**: * `controller.devfile.io/mount-path`: configure where the resource should be mounted +* `controller.devfile.io/mount-sub-path`: for persistent volume claims only, configure a subpath within the PVC to mount instead of its root * `controller.devfile.io/mount-access-mode`: for secrets and configmaps only, configure file permissions on files mounted from this configmap/secret. Permissions can be specified in decimal (e.g. `"511"`) or octal notation by prefixing with a "0" (e.g. `"0777"`) * `controller.devfile.io/mount-as`: for secrets and configmaps only, configure how the resource should be mounted to the workspace + From 5ca5ed64cdd9490cd2a095492d0f07b23af5a657 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Thu, 5 Mar 2026 10:58:11 +0100 Subject: [PATCH 3/3] chore: Support multiple subpath mounts for a single PVC Signed-off-by: Anatolii Bazko --- docs/additional-configuration.adoc | 11 +++- pkg/constants/metadata.go | 6 -- pkg/provision/automount/pvcs.go | 66 ++++++++++++++----- .../testProvisionsPVCWithEmptyArray.yaml | 28 ++++++++ ...ProvisionsPVCWithInvalidMountPathJSON.yaml | 22 +++++++ ...testProvisionsPVCWithMissingPathField.yaml | 22 +++++++ ...testProvisionsPVCWithMultipleSubPaths.yaml | 32 +++++++++ ... => testProvisionsPVCWithSingleMount.yaml} | 2 +- ...tProvisionsPVCWithSingleSubPathEntry.yaml} | 7 +- 9 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 pkg/provision/automount/testdata/testProvisionsPVCWithEmptyArray.yaml create mode 100644 pkg/provision/automount/testdata/testProvisionsPVCWithInvalidMountPathJSON.yaml create mode 100644 pkg/provision/automount/testdata/testProvisionsPVCWithMissingPathField.yaml create mode 100644 pkg/provision/automount/testdata/testProvisionsPVCWithMultipleSubPaths.yaml rename pkg/provision/automount/testdata/{testProvisionsPVCWithoutSubPath.yaml => testProvisionsPVCWithSingleMount.yaml} (89%) rename pkg/provision/automount/testdata/{testProvisionsPVCWithSubPath.yaml => testProvisionsPVCWithSingleSubPathEntry.yaml} (72%) diff --git a/docs/additional-configuration.adoc b/docs/additional-configuration.adoc index b0c994193..68ec4b904 100644 --- a/docs/additional-configuration.adoc +++ b/docs/additional-configuration.adoc @@ -141,7 +141,16 @@ By default, resources will be mounted based on the resource name: Mounting resources can be additionally configured via **annotations**: * `controller.devfile.io/mount-path`: configure where the resource should be mounted -* `controller.devfile.io/mount-sub-path`: for persistent volume claims only, configure a subpath within the PVC to mount instead of its root ++ +For persistent volume claims, `controller.devfile.io/mount-path` also supports a JSON array to mount multiple subdirectories at different paths: ++ +[source,yaml] +---- +annotations: + controller.devfile.io/mount-path: '[{"path":"/var/logs","subPath":"data/logs"},{"path":"/etc/config","subPath":"data/config"}]' +---- ++ +Each entry requires a `path` field (the container mount path) and an optional `subPath` field (the subdirectory within the PVC). An empty or missing annotation falls back to `/tmp/`. * `controller.devfile.io/mount-access-mode`: for secrets and configmaps only, configure file permissions on files mounted from this configmap/secret. Permissions can be specified in decimal (e.g. `"511"`) or octal notation by prefixing with a "0" (e.g. `"0777"`) * `controller.devfile.io/mount-as`: for secrets and configmaps only, configure how the resource should be mounted to the workspace + diff --git a/pkg/constants/metadata.go b/pkg/constants/metadata.go index 1ee607be7..65c803029 100644 --- a/pkg/constants/metadata.go +++ b/pkg/constants/metadata.go @@ -121,12 +121,6 @@ const ( // read-write. Automounted configmaps and secrets are always mounted read-only and this annotation is ignored. DevWorkspaceMountReadyOnlyAnnotation = "controller.devfile.io/read-only" - // DevWorkspaceMountSubPathAnnotation is an annotation to configure a subPath for a mounted PVC volume. - // When set on a PVC with the automount label, the volume mount will use the specified subPath, - // allowing a subdirectory within the PVC to be mounted instead of the root. - // This annotation is only used for PersistentVolumeClaims. - DevWorkspaceMountSubPathAnnotation = "controller.devfile.io/mount-sub-path" - // DevWorkspaceRestrictedAccessAnnotation marks the intention that devworkspace access is restricted to only the creator; setting this // annotation will cause devworkspace start to fail if webhooks are disabled. // Operator also propagates it to the devworkspace-related objects to perform authorization. diff --git a/pkg/provision/automount/pvcs.go b/pkg/provision/automount/pvcs.go index 83dbc82b0..a63c68987 100644 --- a/pkg/provision/automount/pvcs.go +++ b/pkg/provision/automount/pvcs.go @@ -16,16 +16,51 @@ package automount import ( + "encoding/json" + "fmt" "path" + "strings" - "github.com/devfile/devworkspace-operator/pkg/provision/sync" corev1 "k8s.io/api/core/v1" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/provision/sync" ) +type mountPathEntry struct { + Path string `json:"path"` + SubPath string `json:"subPath,omitempty"` +} + +func parseMountPathAnnotation(annotation string, pvcName string) ([]mountPathEntry, error) { + if annotation == "" { + return []mountPathEntry{{Path: path.Join("/tmp/", pvcName)}}, nil + } + + if !strings.HasPrefix(annotation, "[") { + return []mountPathEntry{{Path: annotation}}, nil + } + + var entries []mountPathEntry + if err := json.Unmarshal([]byte(annotation), &entries); err != nil { + return nil, fmt.Errorf("failed to parse mount-path annotation on PVC %s: %w", pvcName, err) + } + + if len(entries) == 0 { + return []mountPathEntry{{Path: path.Join("/tmp/", pvcName)}}, nil + } + + for i, entry := range entries { + if entry.Path == "" { + return nil, fmt.Errorf("mount-path annotation on PVC %s: entry %d is missing required field 'path'", pvcName, i) + } + } + + return entries, nil +} + func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) { pvcs := &corev1.PersistentVolumeClaimList{} if err := api.Client.List(api.Ctx, pvcs, k8sclient.InNamespace(namespace), k8sclient.MatchingLabels{ @@ -40,16 +75,7 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) var volumes []corev1.Volume var volumeMounts []corev1.VolumeMount for _, pvc := range pvcs.Items { - mountPath := pvc.Annotations[constants.DevWorkspaceMountPathAnnotation] - subPath := pvc.Annotations[constants.DevWorkspaceMountSubPathAnnotation] - if mountPath == "" { - mountPath = path.Join("/tmp/", pvc.Name) - } - - mountReadOnly := false - if pvc.Annotations[constants.DevWorkspaceMountReadyOnlyAnnotation] == "true" { - mountReadOnly = true - } + mountReadOnly := pvc.Annotations[constants.DevWorkspaceMountReadyOnlyAnnotation] == "true" volumes = append(volumes, corev1.Volume{ Name: common.AutoMountPVCVolumeName(pvc.Name), @@ -60,11 +86,19 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) }, }, }) - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: common.AutoMountPVCVolumeName(pvc.Name), - MountPath: mountPath, - SubPath: subPath, - }) + + mountPathEntries, err := parseMountPathAnnotation(pvc.Annotations[constants.DevWorkspaceMountPathAnnotation], pvc.Name) + if err != nil { + return nil, err + } + + for _, entry := range mountPathEntries { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: common.AutoMountPVCVolumeName(pvc.Name), + MountPath: entry.Path, + SubPath: entry.SubPath, + }) + } } return &Resources{ Volumes: volumes, diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithEmptyArray.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithEmptyArray.yaml new file mode 100644 index 000000000..fd174a641 --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithEmptyArray.yaml @@ -0,0 +1,28 @@ +name: Provisions automount PVC with empty array falls back to default mount path + +input: + pvcs: + - + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: test-pvc + labels: + controller.devfile.io/mount-to-devworkspace: "true" + annotations: + controller.devfile.io/mount-path: '[]' + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + volumes: + - name: test-pvc + persistentVolumeClaim: + claimName: test-pvc + volumeMounts: + - name: test-pvc + mountPath: /tmp/test-pvc diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithInvalidMountPathJSON.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithInvalidMountPathJSON.yaml new file mode 100644 index 000000000..32c748e03 --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithInvalidMountPathJSON.yaml @@ -0,0 +1,22 @@ +name: Returns error for invalid JSON in mount-path annotation + +input: + pvcs: + - + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: test-pvc + labels: + controller.devfile.io/mount-to-devworkspace: "true" + annotations: + controller.devfile.io/mount-path: '[{"path":"/data"' + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + errRegexp: "failed to parse mount-path annotation on PVC test-pvc" diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithMissingPathField.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithMissingPathField.yaml new file mode 100644 index 000000000..d3f3069f8 --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithMissingPathField.yaml @@ -0,0 +1,22 @@ +name: Returns error when path field is missing in mount-path JSON entry + +input: + pvcs: + - + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: test-pvc + labels: + controller.devfile.io/mount-to-devworkspace: "true" + annotations: + controller.devfile.io/mount-path: '[{"subPath":"data/logs"}]' + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + errRegexp: "mount-path annotation on PVC test-pvc: entry 0 is missing required field 'path'" diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithMultipleSubPaths.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithMultipleSubPaths.yaml new file mode 100644 index 000000000..ca33b9368 --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithMultipleSubPaths.yaml @@ -0,0 +1,32 @@ +name: Provisions automount PVC with multiple subpath mounts via JSON array + +input: + pvcs: + - + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: test-pvc + labels: + controller.devfile.io/mount-to-devworkspace: "true" + annotations: + controller.devfile.io/mount-path: '[{"path":"/var/logs","subPath":"data/logs"},{"path":"/etc/config","subPath":"data/config"}]' + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + volumes: + - name: test-pvc + persistentVolumeClaim: + claimName: test-pvc + volumeMounts: + - name: test-pvc + mountPath: /var/logs + subPath: data/logs + - name: test-pvc + mountPath: /etc/config + subPath: data/config diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithoutSubPath.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleMount.yaml similarity index 89% rename from pkg/provision/automount/testdata/testProvisionsPVCWithoutSubPath.yaml rename to pkg/provision/automount/testdata/testProvisionsPVCWithSingleMount.yaml index 5e1307ab1..5dd9892de 100644 --- a/pkg/provision/automount/testdata/testProvisionsPVCWithoutSubPath.yaml +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleMount.yaml @@ -1,4 +1,4 @@ -name: Provisions automount PVC without subPath +name: Provisions automount PVC with plain string mount-path input: pvcs: diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithSubPath.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleSubPathEntry.yaml similarity index 72% rename from pkg/provision/automount/testdata/testProvisionsPVCWithSubPath.yaml rename to pkg/provision/automount/testdata/testProvisionsPVCWithSingleSubPathEntry.yaml index 63164684d..06af55271 100644 --- a/pkg/provision/automount/testdata/testProvisionsPVCWithSubPath.yaml +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleSubPathEntry.yaml @@ -1,4 +1,4 @@ -name: Provisions automount PVC with subPath +name: Provisions automount PVC with single entry JSON array input: pvcs: @@ -10,8 +10,7 @@ input: labels: controller.devfile.io/mount-to-devworkspace: "true" annotations: - controller.devfile.io/mount-path: /data - controller.devfile.io/mount-sub-path: my/subdirectory + controller.devfile.io/mount-path: '[{"path":"/data","subPath":"mydir"}]' spec: accessModes: - ReadWriteOnce @@ -27,4 +26,4 @@ output: volumeMounts: - name: test-pvc mountPath: /data - subPath: my/subdirectory + subPath: mydir