diff --git a/docs/additional-configuration.adoc b/docs/additional-configuration.adoc index 8418fd4cb..68ec4b904 100644 --- a/docs/additional-configuration.adoc +++ b/docs/additional-configuration.adoc @@ -141,6 +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 ++ +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 b9664e3e1..65c803029 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 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..a63c68987 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 @@ -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,15 +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] - 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), @@ -59,10 +86,19 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) }, }, }) - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: common.AutoMountPVCVolumeName(pvc.Name), - MountPath: mountPath, - }) + + 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/testProvisionsPVCWithSingleMount.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleMount.yaml new file mode 100644 index 000000000..5dd9892de --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleMount.yaml @@ -0,0 +1,28 @@ +name: Provisions automount PVC with plain string 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: /data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + volumes: + - name: test-pvc + persistentVolumeClaim: + claimName: test-pvc + volumeMounts: + - name: test-pvc + mountPath: /data diff --git a/pkg/provision/automount/testdata/testProvisionsPVCWithSingleSubPathEntry.yaml b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleSubPathEntry.yaml new file mode 100644 index 000000000..06af55271 --- /dev/null +++ b/pkg/provision/automount/testdata/testProvisionsPVCWithSingleSubPathEntry.yaml @@ -0,0 +1,29 @@ +name: Provisions automount PVC with single entry 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":"/data","subPath":"mydir"}]' + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +output: + volumes: + - name: test-pvc + persistentVolumeClaim: + claimName: test-pvc + volumeMounts: + - name: test-pvc + mountPath: /data + subPath: mydir