Skip to content

Commit cce29e8

Browse files
authored
feat: Mounting PVC into Workspace within Subpath (#1595)
* feat: Mounting PVC into Workspace within Subpath Signed-off-by: Anatolii Bazko <abazko@redhat.com> * Update doc Signed-off-by: Anatolii Bazko <abazko@redhat.com> * chore: Support multiple subpath mounts for a single PVC Signed-off-by: Anatolii Bazko <abazko@redhat.com> --------- Signed-off-by: Anatolii Bazko <abazko@redhat.com>
1 parent 9415b15 commit cce29e8

10 files changed

+232
-21
lines changed

docs/additional-configuration.adoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,16 @@ By default, resources will be mounted based on the resource name:
141141
Mounting resources can be additionally configured via **annotations**:
142142

143143
* `controller.devfile.io/mount-path`: configure where the resource should be mounted
144+
+
145+
For persistent volume claims, `controller.devfile.io/mount-path` also supports a JSON array to mount multiple subdirectories at different paths:
146+
+
147+
[source,yaml]
148+
----
149+
annotations:
150+
controller.devfile.io/mount-path: '[{"path":"/var/logs","subPath":"data/logs"},{"path":"/etc/config","subPath":"data/config"}]'
151+
----
152+
+
153+
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/<pvc-name>`.
144154
* `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"`)
145155
* `controller.devfile.io/mount-as`: for secrets and configmaps only, configure how the resource should be mounted to the workspace
146156
+

pkg/constants/metadata.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright (c) 2019-2025 Red Hat, Inc.
2+
// Copyright (c) 2019-2026 Red Hat, Inc.
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
55
// You may obtain a copy of the License at

pkg/provision/automount/common_test.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright (c) 2019-2025 Red Hat, Inc.
2+
// Copyright (c) 2019-2026 Red Hat, Inc.
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
55
// You may obtain a copy of the License at
@@ -51,10 +51,11 @@ const (
5151
type testCase struct {
5252
Name string `json:"name"`
5353
Input struct {
54-
// Secrets and Configmaps are necessary for deserialization from a testcase
55-
Secrets []corev1.Secret `json:"secrets"`
56-
ConfigMaps []corev1.ConfigMap `json:"configmaps"`
57-
// allObjects contains all Secrets and Configmaps defined above, for convenience
54+
// Secrets, Configmaps, and PVCs are necessary for deserialization from a testcase
55+
Secrets []corev1.Secret `json:"secrets"`
56+
ConfigMaps []corev1.ConfigMap `json:"configmaps"`
57+
PVCs []corev1.PersistentVolumeClaim `json:"pvcs"`
58+
// allObjects contains all Secrets, Configmaps, and PVCs defined above, for convenience
5859
allObjects []client.Object
5960
} `json:"input"`
6061
Output struct {
@@ -382,6 +383,9 @@ func loadTestCaseOrPanic(t *testing.T, testPath string) testCase {
382383
for idx := range test.Input.Secrets {
383384
test.Input.allObjects = append(test.Input.allObjects, &test.Input.Secrets[idx])
384385
}
386+
for idx := range test.Input.PVCs {
387+
test.Input.allObjects = append(test.Input.allObjects, &test.Input.PVCs[idx])
388+
}
385389

386390
// Overwrite namespace for convenience
387391
for _, obj := range test.Input.allObjects {

pkg/provision/automount/pvcs.go

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright (c) 2019-2025 Red Hat, Inc.
2+
// Copyright (c) 2019-2026 Red Hat, Inc.
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
55
// You may obtain a copy of the License at
@@ -16,16 +16,51 @@
1616
package automount
1717

1818
import (
19+
"encoding/json"
20+
"fmt"
1921
"path"
22+
"strings"
2023

21-
"github.com/devfile/devworkspace-operator/pkg/provision/sync"
2224
corev1 "k8s.io/api/core/v1"
2325
k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
2426

2527
"github.com/devfile/devworkspace-operator/pkg/common"
2628
"github.com/devfile/devworkspace-operator/pkg/constants"
29+
"github.com/devfile/devworkspace-operator/pkg/provision/sync"
2730
)
2831

32+
type mountPathEntry struct {
33+
Path string `json:"path"`
34+
SubPath string `json:"subPath,omitempty"`
35+
}
36+
37+
func parseMountPathAnnotation(annotation string, pvcName string) ([]mountPathEntry, error) {
38+
if annotation == "" {
39+
return []mountPathEntry{{Path: path.Join("/tmp/", pvcName)}}, nil
40+
}
41+
42+
if !strings.HasPrefix(annotation, "[") {
43+
return []mountPathEntry{{Path: annotation}}, nil
44+
}
45+
46+
var entries []mountPathEntry
47+
if err := json.Unmarshal([]byte(annotation), &entries); err != nil {
48+
return nil, fmt.Errorf("failed to parse mount-path annotation on PVC %s: %w", pvcName, err)
49+
}
50+
51+
if len(entries) == 0 {
52+
return []mountPathEntry{{Path: path.Join("/tmp/", pvcName)}}, nil
53+
}
54+
55+
for i, entry := range entries {
56+
if entry.Path == "" {
57+
return nil, fmt.Errorf("mount-path annotation on PVC %s: entry %d is missing required field 'path'", pvcName, i)
58+
}
59+
}
60+
61+
return entries, nil
62+
}
63+
2964
func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) {
3065
pvcs := &corev1.PersistentVolumeClaimList{}
3166
if err := api.Client.List(api.Ctx, pvcs, k8sclient.InNamespace(namespace), k8sclient.MatchingLabels{
@@ -40,15 +75,7 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error)
4075
var volumes []corev1.Volume
4176
var volumeMounts []corev1.VolumeMount
4277
for _, pvc := range pvcs.Items {
43-
mountPath := pvc.Annotations[constants.DevWorkspaceMountPathAnnotation]
44-
if mountPath == "" {
45-
mountPath = path.Join("/tmp/", pvc.Name)
46-
}
47-
48-
mountReadOnly := false
49-
if pvc.Annotations[constants.DevWorkspaceMountReadyOnlyAnnotation] == "true" {
50-
mountReadOnly = true
51-
}
78+
mountReadOnly := pvc.Annotations[constants.DevWorkspaceMountReadyOnlyAnnotation] == "true"
5279

5380
volumes = append(volumes, corev1.Volume{
5481
Name: common.AutoMountPVCVolumeName(pvc.Name),
@@ -59,10 +86,19 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error)
5986
},
6087
},
6188
})
62-
volumeMounts = append(volumeMounts, corev1.VolumeMount{
63-
Name: common.AutoMountPVCVolumeName(pvc.Name),
64-
MountPath: mountPath,
65-
})
89+
90+
mountPathEntries, err := parseMountPathAnnotation(pvc.Annotations[constants.DevWorkspaceMountPathAnnotation], pvc.Name)
91+
if err != nil {
92+
return nil, err
93+
}
94+
95+
for _, entry := range mountPathEntries {
96+
volumeMounts = append(volumeMounts, corev1.VolumeMount{
97+
Name: common.AutoMountPVCVolumeName(pvc.Name),
98+
MountPath: entry.Path,
99+
SubPath: entry.SubPath,
100+
})
101+
}
66102
}
67103
return &Resources{
68104
Volumes: volumes,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Provisions automount PVC with empty array falls back to default mount path
2+
3+
input:
4+
pvcs:
5+
-
6+
apiVersion: v1
7+
kind: PersistentVolumeClaim
8+
metadata:
9+
name: test-pvc
10+
labels:
11+
controller.devfile.io/mount-to-devworkspace: "true"
12+
annotations:
13+
controller.devfile.io/mount-path: '[]'
14+
spec:
15+
accessModes:
16+
- ReadWriteOnce
17+
resources:
18+
requests:
19+
storage: 1Gi
20+
21+
output:
22+
volumes:
23+
- name: test-pvc
24+
persistentVolumeClaim:
25+
claimName: test-pvc
26+
volumeMounts:
27+
- name: test-pvc
28+
mountPath: /tmp/test-pvc
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Returns error for invalid JSON in mount-path annotation
2+
3+
input:
4+
pvcs:
5+
-
6+
apiVersion: v1
7+
kind: PersistentVolumeClaim
8+
metadata:
9+
name: test-pvc
10+
labels:
11+
controller.devfile.io/mount-to-devworkspace: "true"
12+
annotations:
13+
controller.devfile.io/mount-path: '[{"path":"/data"'
14+
spec:
15+
accessModes:
16+
- ReadWriteOnce
17+
resources:
18+
requests:
19+
storage: 1Gi
20+
21+
output:
22+
errRegexp: "failed to parse mount-path annotation on PVC test-pvc"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Returns error when path field is missing in mount-path JSON entry
2+
3+
input:
4+
pvcs:
5+
-
6+
apiVersion: v1
7+
kind: PersistentVolumeClaim
8+
metadata:
9+
name: test-pvc
10+
labels:
11+
controller.devfile.io/mount-to-devworkspace: "true"
12+
annotations:
13+
controller.devfile.io/mount-path: '[{"subPath":"data/logs"}]'
14+
spec:
15+
accessModes:
16+
- ReadWriteOnce
17+
resources:
18+
requests:
19+
storage: 1Gi
20+
21+
output:
22+
errRegexp: "mount-path annotation on PVC test-pvc: entry 0 is missing required field 'path'"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Provisions automount PVC with multiple subpath mounts via JSON array
2+
3+
input:
4+
pvcs:
5+
-
6+
apiVersion: v1
7+
kind: PersistentVolumeClaim
8+
metadata:
9+
name: test-pvc
10+
labels:
11+
controller.devfile.io/mount-to-devworkspace: "true"
12+
annotations:
13+
controller.devfile.io/mount-path: '[{"path":"/var/logs","subPath":"data/logs"},{"path":"/etc/config","subPath":"data/config"}]'
14+
spec:
15+
accessModes:
16+
- ReadWriteOnce
17+
resources:
18+
requests:
19+
storage: 1Gi
20+
21+
output:
22+
volumes:
23+
- name: test-pvc
24+
persistentVolumeClaim:
25+
claimName: test-pvc
26+
volumeMounts:
27+
- name: test-pvc
28+
mountPath: /var/logs
29+
subPath: data/logs
30+
- name: test-pvc
31+
mountPath: /etc/config
32+
subPath: data/config
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Provisions automount PVC with plain string mount-path
2+
3+
input:
4+
pvcs:
5+
-
6+
apiVersion: v1
7+
kind: PersistentVolumeClaim
8+
metadata:
9+
name: test-pvc
10+
labels:
11+
controller.devfile.io/mount-to-devworkspace: "true"
12+
annotations:
13+
controller.devfile.io/mount-path: /data
14+
spec:
15+
accessModes:
16+
- ReadWriteOnce
17+
resources:
18+
requests:
19+
storage: 1Gi
20+
21+
output:
22+
volumes:
23+
- name: test-pvc
24+
persistentVolumeClaim:
25+
claimName: test-pvc
26+
volumeMounts:
27+
- name: test-pvc
28+
mountPath: /data
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Provisions automount PVC with single entry JSON array
2+
3+
input:
4+
pvcs:
5+
-
6+
apiVersion: v1
7+
kind: PersistentVolumeClaim
8+
metadata:
9+
name: test-pvc
10+
labels:
11+
controller.devfile.io/mount-to-devworkspace: "true"
12+
annotations:
13+
controller.devfile.io/mount-path: '[{"path":"/data","subPath":"mydir"}]'
14+
spec:
15+
accessModes:
16+
- ReadWriteOnce
17+
resources:
18+
requests:
19+
storage: 1Gi
20+
21+
output:
22+
volumes:
23+
- name: test-pvc
24+
persistentVolumeClaim:
25+
claimName: test-pvc
26+
volumeMounts:
27+
- name: test-pvc
28+
mountPath: /data
29+
subPath: mydir

0 commit comments

Comments
 (0)