diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index 17f62d83a..240f17266 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.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 @@ -454,8 +454,18 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request return r.failWorkspace(workspace, fmt.Sprintf("Failed to mount SSH askpass script to workspace: %s", err), metrics.ReasonWorkspaceEngineFailure, reqLogger, &reconcileStatus), nil } - // Add automount resources into devfile containers - err = automount.ProvisionAutoMountResourcesInto(devfilePodAdditions, clusterAPI, workspace.Namespace, home.PersistUserHomeEnabled(workspace)) + var workspaceDeployment *appsv1.Deployment + + if workspace.Status.Phase == dw.DevWorkspaceStatusRunning { + // Fetch the existing deployment to determine whether automount resources with + // `controller.devfile.io/mount-on-start=true` can be mounted without a restart. + // Only needed when the workspace is already running; skip otherwise to reduce API calls. + if workspaceDeployment, err = wsprovision.GetClusterDeployment(workspace, clusterAPI); err != nil { + return reconcile.Result{}, err + } + } + + err = automount.ProvisionAutoMountResourcesInto(devfilePodAdditions, clusterAPI, workspace.Namespace, home.PersistUserHomeEnabled(workspace), workspaceDeployment) if shouldReturn, reconcileResult, reconcileErr := r.checkDWError(workspace, err, "Failed to process automount resources", metrics.ReasonBadRequest, reqLogger, &reconcileStatus); shouldReturn { return reconcileResult, reconcileErr } diff --git a/docs/additional-configuration.adoc b/docs/additional-configuration.adoc index 68ec4b904..bf689cac7 100644 --- a/docs/additional-configuration.adoc +++ b/docs/additional-configuration.adoc @@ -166,6 +166,17 @@ When "file" is used, the configmap is mounted as a directory within the workspac * `controller.devfile.io/read-only`: for persistent volume claims, mount the resource as read-only +* `controller.devfile.io/mount-on-start`: when set to `"true"`, the resource will only be mounted when a workspace starts. If the resource is created while a workspace is already running, it will not be automatically mounted until the workspace is restarted. This prevents unwanted workspace restarts caused by newly created automount resources. This annotation can be applied to configmaps, secrets, and persistent volume claims. ++ +For git credential secrets (labelled with `controller.devfile.io/git-credential`) and git TLS configmaps (labelled with `controller.devfile.io/git-tls-credential`), the annotation works similarly but applies collectively: since all git credentials are merged into a single mounted secret, if at least one git credential secret (or TLS configmap) lacks the `controller.devfile.io/mount-on-start` annotation, all git credentials (or TLS configmaps) will be mounted, including those marked with `mount-on-start`. ++ +[source,yaml] +---- +metadata: + annotations: + controller.devfile.io/mount-on-start: "true" +---- + ## Adding image pull secrets to workspaces Labelling secrets with `controller.devfile.io/devworkspace_pullsecret: true` marks a secret as the Docker pull secret for the workspace deployment. This should be applied to secrets with docker config types (`kubernetes.io/dockercfg` and `kubernetes.io/dockerconfigjson`) diff --git a/pkg/constants/attributes.go b/pkg/constants/attributes.go index c9dded218..fb03df339 100644 --- a/pkg/constants/attributes.go +++ b/pkg/constants/attributes.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 @@ -171,4 +171,10 @@ const ( // controller.devfile.io/restore-source-image: "registry.example.com/backups/my-workspace:20241111-123456" // WorkspaceRestoreSourceImageAttribute = "controller.devfile.io/restore-source-image" + + // MountOnStartAttribute is an attribute applied to Kubernetes resources to indicate that they should only + // be mounted to a workspace when it starts. When this attribute is set to "true", newly created + // resources will not be automatically mounted to running workspaces, preventing unwanted workspace + // restarts. + MountOnStartAttribute = "controller.devfile.io/mount-on-start" ) diff --git a/pkg/provision/automount/common.go b/pkg/provision/automount/common.go index 9265e05c6..2818e7423 100644 --- a/pkg/provision/automount/common.go +++ b/pkg/provision/automount/common.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 @@ -24,6 +24,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/dwerrors" + appsv1 "k8s.io/api/apps/v1" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" @@ -42,8 +43,14 @@ type Resources struct { EnvFromSource []corev1.EnvFromSource } -func ProvisionAutoMountResourcesInto(podAdditions *v1alpha1.PodAdditions, api sync.ClusterAPI, namespace string, persistentHome bool) error { - resources, err := getAutomountResources(api, namespace) +func ProvisionAutoMountResourcesInto( + podAdditions *v1alpha1.PodAdditions, + api sync.ClusterAPI, + namespace string, + persistentHome bool, + workspaceDeployment *appsv1.Deployment, +) error { + resources, err := getAutomountResources(api, namespace, workspaceDeployment) if err != nil { return err @@ -76,18 +83,22 @@ func ProvisionAutoMountResourcesInto(podAdditions *v1alpha1.PodAdditions, api sy return nil } -func getAutomountResources(api sync.ClusterAPI, namespace string) (*Resources, error) { - gitCMAutoMountResources, err := ProvisionGitConfiguration(api, namespace) +func getAutomountResources( + api sync.ClusterAPI, + namespace string, + workspaceDeployment *appsv1.Deployment, +) (*Resources, error) { + gitCMAutoMountResources, err := ProvisionGitConfiguration(api, namespace, workspaceDeployment) if err != nil { return nil, err } - cmAutoMountResources, err := getDevWorkspaceConfigmaps(namespace, api) + cmAutoMountResources, err := getDevWorkspaceConfigmaps(namespace, api, workspaceDeployment) if err != nil { return nil, err } - secretAutoMountResources, err := getDevWorkspaceSecrets(namespace, api) + secretAutoMountResources, err := getDevWorkspaceSecrets(namespace, api, workspaceDeployment) if err != nil { return nil, err } @@ -104,7 +115,7 @@ func getAutomountResources(api sync.ClusterAPI, namespace string) (*Resources, e } dropItemsFieldFromVolumes(mergedResources.Volumes) - pvcAutoMountResources, err := getAutoMountPVCs(namespace, api) + pvcAutoMountResources, err := getAutoMountPVCs(namespace, api, workspaceDeployment) if err != nil { return nil, err } @@ -354,3 +365,75 @@ func sortConfigmaps(cms []corev1.ConfigMap) { return cms[i].Name < cms[j].Name }) } + +func isMountOnStart(obj k8sclient.Object) bool { + return obj.GetAnnotations()[constants.MountOnStartAttribute] == "true" +} + +// isAllowedToMount checks whether an automount resource can be added to the workspace pod. +// Resources marked with mount-on-start are only allowed when +// the workspace is not yet running or when they are already present in the current deployment. +func isAllowedToMount( + obj k8sclient.Object, + automountResource Resources, + workspaceDeployment *appsv1.Deployment, +) bool { + // No existing deployment — workspace is not yet running, allow everything + if workspaceDeployment == nil { + return true + } + + // Resource without mount-on-start is always eligible + if !isMountOnStart(obj) { + return true + } + + // Workspace is already running — only allow if already present in the deployment + return existsInDeployment(automountResource, workspaceDeployment) +} + +func existsInDeployment(automountResource Resources, workspaceDeployment *appsv1.Deployment) bool { + return isVolumeMountExistsInDeployment(automountResource, workspaceDeployment) || + isEnvFromSourceExistsInDeployment(automountResource, workspaceDeployment) +} + +// isVolumeMountExistsInDeployment returns true if any volume from the automount resource +// is already present in the workspace deployment's pod spec. Comparison is by name only, +// ignoring VolumeSource — if a name is reused after deleting the old resource, the deletion +// triggers reconciliation and a workspace restart before the new resource is mounted. +func isVolumeMountExistsInDeployment(automountResource Resources, workspaceDeployment *appsv1.Deployment) bool { + for _, automountVolume := range automountResource.Volumes { + for _, deploymentVolume := range workspaceDeployment.Spec.Template.Spec.Volumes { + if automountVolume.Name == deploymentVolume.Name { + return true + } + } + } + + return false +} + +// isEnvFromSourceExistsInDeployment returns true if any EnvFromSource from the automount resource +// is already referenced in a container of the workspace deployment, matched by ConfigMap or Secret name. +func isEnvFromSourceExistsInDeployment(automountResource Resources, workspaceDeployment *appsv1.Deployment) bool { + for _, container := range workspaceDeployment.Spec.Template.Spec.Containers { + + for _, automountEnvFrom := range automountResource.EnvFromSource { + for _, containerEnvFrom := range container.EnvFrom { + if automountEnvFrom.ConfigMapRef != nil && containerEnvFrom.ConfigMapRef != nil && + automountEnvFrom.ConfigMapRef.Name == containerEnvFrom.ConfigMapRef.Name { + + return true + } + + if automountEnvFrom.SecretRef != nil && containerEnvFrom.SecretRef != nil && + automountEnvFrom.SecretRef.Name == containerEnvFrom.SecretRef.Name { + + return true + } + } + } + } + + return false +} diff --git a/pkg/provision/automount/common_persistenthome_test.go b/pkg/provision/automount/common_persistenthome_test.go index b1f71d0f8..549037962 100644 --- a/pkg/provision/automount/common_persistenthome_test.go +++ b/pkg/provision/automount/common_persistenthome_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 @@ -56,7 +56,7 @@ func TestProvisionAutomountResourcesIntoPersistentHomeEnabled(t *testing.T) { Client: fake.NewClientBuilder().WithObjects(tt.Input.allObjects...).Build(), } - err := ProvisionAutoMountResourcesInto(podAdditions, testAPI, testNamespace, true) + err := ProvisionAutoMountResourcesInto(podAdditions, testAPI, testNamespace, true, nil) if !assert.NoError(t, err, "Unexpected error") { return diff --git a/pkg/provision/automount/common_test.go b/pkg/provision/automount/common_test.go index df046c9be..677ce0bd0 100644 --- a/pkg/provision/automount/common_test.go +++ b/pkg/provision/automount/common_test.go @@ -25,7 +25,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/yaml" @@ -126,7 +128,7 @@ func TestProvisionAutomountResourcesInto(t *testing.T) { } // Note: this test does not allow for returning AutoMountError with isFatal: false (i.e. no retrying) // and so is not suitable for testing automount features that provision cluster resources (yet) - err := ProvisionAutoMountResourcesInto(podAdditions, testAPI, testNamespace, false) + err := ProvisionAutoMountResourcesInto(podAdditions, testAPI, testNamespace, false, nil) if tt.Output.ErrRegexp != nil { if !assert.Error(t, err, "Expected an error but got none") { return @@ -407,3 +409,417 @@ func loadTestCaseOrPanic(t *testing.T, testPath string) testCase { test.TestPath = testPath return test } + +func TestShouldNotMountSecretWithMountOnStartIfWorkspaceStarted(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartSecretAsFile()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, emptyDeployment()) + assert.NoError(t, err) + assert.Empty(t, testPodAdditions.Volumes) + assert.Empty(t, testPodAdditions.Containers[0].VolumeMounts) +} + +func TestMountSecretWithMountOnStartIfWorkspaceNotStarted(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartSecretAsFile()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, nil) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Volumes, 1) + assert.Len(t, testPodAdditions.Containers[0].VolumeMounts, 1) + assert.Equal(t, common.AutoMountSecretVolumeName("test-secret"), testPodAdditions.Volumes[0].Name) +} + +func TestShouldNotMountConfigMapWithMountOnStartIfWorkspaceStarted(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartConfigMapAsFile()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, emptyDeployment()) + assert.NoError(t, err) + assert.Empty(t, testPodAdditions.Volumes) + assert.Empty(t, testPodAdditions.Containers[0].VolumeMounts) +} + +func TestMountConfigMapWithMountOnStartIfWorkspaceNotStarted(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartConfigMapAsFile()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, nil) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Volumes, 1) + assert.Len(t, testPodAdditions.Containers[0].VolumeMounts, 1) + assert.Equal(t, common.AutoMountConfigMapVolumeName("test-cm"), testPodAdditions.Volumes[0].Name) +} + +func TestShouldNotMountPVCWithMountOnStartIfWorkspaceStarted(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartPVC()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, emptyDeployment()) + assert.NoError(t, err) + assert.Empty(t, testPodAdditions.Volumes) + assert.Empty(t, testPodAdditions.Containers[0].VolumeMounts) +} + +func TestMountPVCWithMountOnStartIfWorkspaceNotStarted(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartPVC()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, nil) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Volumes, 1) + assert.Len(t, testPodAdditions.Containers[0].VolumeMounts, 1) + assert.Equal(t, common.AutoMountPVCVolumeName("test-pvc"), testPodAdditions.Volumes[0].Name) +} + +func TestShouldNotMountConfigMapWithMountOnStartWhenRunningAndNotInDeployment(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartConfigMapAsFile()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, emptyDeployment()) + assert.NoError(t, err) + assert.Empty(t, testPodAdditions.Volumes) + assert.Empty(t, testPodAdditions.Containers[0].VolumeMounts) +} + +func TestMountOnStartConfigMapAsEnvAllowedWhenEnvFromExistsInDeployment(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartConfigMapAsEnv()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "test-container", + EnvFrom: []corev1.EnvFromSource{{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, + }, + }}, + }}, + }, + }, + }, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, deployment) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Containers[0].EnvFrom, 1) + assert.Equal(t, common.AutoMountConfigMapVolumeName("test-cm"), testPodAdditions.Containers[0].EnvFrom[0].ConfigMapRef.Name) +} + +func TestMountOnStartConfigMapAsFileAllowedWhenVolumeExistsInDeployment(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartConfigMapAsFile()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test-container"}}, + Volumes: []corev1.Volume{{Name: "test-cm"}}, + }, + }, + }, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, deployment) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Volumes, 1) + assert.Equal(t, common.AutoMountConfigMapVolumeName("test-cm"), testPodAdditions.Volumes[0].Name) +} + +func TestMountOnStartSecretAsEnvAllowedWhenEnvFromExistsInDeployment(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartSecretAsEnv()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "test-container", + EnvFrom: []corev1.EnvFromSource{{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "test-secret"}, + }, + }}, + }}, + }, + }, + }, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, deployment) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Containers[0].EnvFrom, 1) + assert.Equal(t, common.AutoMountSecretVolumeName("test-secret"), testPodAdditions.Containers[0].EnvFrom[0].SecretRef.Name) +} + +func TestMountOnStartSecretAsFileAllowedWhenVolumeExistsInDeployment(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartSecretAsFile()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test-container"}}, + Volumes: []corev1.Volume{{Name: "test-secret"}}, + }, + }, + }, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, deployment) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Volumes, 1) + assert.Equal(t, common.AutoMountSecretVolumeName("test-secret"), testPodAdditions.Volumes[0].Name) +} + +func TestShouldNotMountPVCWithMountOnStartWhenRunningAndNotInDeployment(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartPVC()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, emptyDeployment()) + assert.NoError(t, err) + assert.Empty(t, testPodAdditions.Volumes) + assert.Empty(t, testPodAdditions.Containers[0].VolumeMounts) +} + +func TestMountOnStartPVCAllowedWhenVolumeExistsInDeployment(t *testing.T) { + testAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(mountOnStartPVC()).Build(), + } + + testPodAdditions := &v1alpha1.PodAdditions{ + Containers: []corev1.Container{{ + Name: "test-container", + Image: "test-image", + }}, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test-container"}}, + Volumes: []corev1.Volume{{Name: common.AutoMountPVCVolumeName("test-pvc")}}, + }, + }, + }, + } + + err := ProvisionAutoMountResourcesInto(testPodAdditions, testAPI, testNamespace, false, deployment) + assert.NoError(t, err) + assert.Len(t, testPodAdditions.Volumes, 1) + assert.Equal(t, common.AutoMountPVCVolumeName("test-pvc"), testPodAdditions.Volumes[0].Name) +} + +func emptyDeployment() *appsv1.Deployment { + return &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test-container"}}, + }, + }, + }, + } +} + +func mountOnStartSecretAsFile() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: testNamespace, + Labels: map[string]string{ + "controller.devfile.io/mount-to-devworkspace": "true", + "controller.devfile.io/watch-secret": "true", + }, + Annotations: map[string]string{ + "controller.devfile.io/mount-as": "file", + "controller.devfile.io/mount-path": "/test/path", + "controller.devfile.io/mount-on-start": "true", + }, + }, + Data: map[string][]byte{ + "data": []byte("test"), + }, + } +} + +func mountOnStartSecretAsEnv() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: testNamespace, + Labels: map[string]string{ + "controller.devfile.io/mount-to-devworkspace": "true", + "controller.devfile.io/watch-secret": "true", + }, + Annotations: map[string]string{ + "controller.devfile.io/mount-as": "env", + "controller.devfile.io/mount-on-start": "true", + }, + }, + Data: map[string][]byte{ + "data": []byte("test"), + }, + } +} + +func mountOnStartConfigMapAsFile() *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: testNamespace, + Labels: map[string]string{ + "controller.devfile.io/mount-to-devworkspace": "true", + "controller.devfile.io/watch-configmap": "true", + }, + Annotations: map[string]string{ + "controller.devfile.io/mount-as": "file", + "controller.devfile.io/mount-path": "/test/path", + "controller.devfile.io/mount-on-start": "true", + }, + }, + Data: map[string]string{ + "data": "test", + }, + } +} + +func mountOnStartConfigMapAsEnv() *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: testNamespace, + Labels: map[string]string{ + "controller.devfile.io/mount-to-devworkspace": "true", + "controller.devfile.io/watch-configmap": "true", + }, + Annotations: map[string]string{ + "controller.devfile.io/mount-as": "env", + "controller.devfile.io/mount-on-start": "true", + }, + }, + Data: map[string]string{ + "data": "test", + }, + } +} + +func mountOnStartPVC() *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: testNamespace, + Labels: map[string]string{ + "controller.devfile.io/mount-to-devworkspace": "true", + }, + Annotations: map[string]string{ + "controller.devfile.io/mount-path": "/test/path", + "controller.devfile.io/mount-on-start": "true", + }, + }, + } +} diff --git a/pkg/provision/automount/configmap.go b/pkg/provision/automount/configmap.go index fb4dbde70..e4ae2f633 100644 --- a/pkg/provision/automount/configmap.go +++ b/pkg/provision/automount/configmap.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 @@ -20,16 +20,17 @@ import ( "path" "sort" - "github.com/devfile/devworkspace-operator/pkg/common" - "github.com/devfile/devworkspace-operator/pkg/dwerrors" - "github.com/devfile/devworkspace-operator/pkg/provision/sync" + appsv1 "k8s.io/api/apps/v1" 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/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/provision/sync" ) -func getDevWorkspaceConfigmaps(namespace string, api sync.ClusterAPI) (*Resources, error) { +func getDevWorkspaceConfigmaps(namespace string, api sync.ClusterAPI, workspaceDeployment *appsv1.Deployment) (*Resources, error) { configmaps := &corev1.ConfigMapList{} if err := api.Client.List(api.Ctx, configmaps, k8sclient.InNamespace(namespace), k8sclient.MatchingLabels{ constants.DevWorkspaceMountLabel: "true", @@ -37,11 +38,10 @@ func getDevWorkspaceConfigmaps(namespace string, api sync.ClusterAPI) (*Resource return nil, err } sortConfigmaps(configmaps.Items) - var allAutoMountResouces []Resources + + var allAutoMountResources []Resources + for _, configmap := range configmaps.Items { - if msg := checkAutomountVolumeForPotentialError(&configmap); msg != "" { - return nil, &dwerrors.FailError{Message: msg} - } mountAs := configmap.Annotations[constants.DevWorkspaceMountAsAnnotation] mountPath := configmap.Annotations[constants.DevWorkspaceMountPathAnnotation] if mountPath == "" { @@ -55,9 +55,19 @@ func getDevWorkspaceConfigmaps(namespace string, api sync.ClusterAPI) (*Resource } } - allAutoMountResouces = append(allAutoMountResouces, getAutomountConfigmap(mountPath, mountAs, accessMode, &configmap)) + automountCM := getAutomountConfigmap(mountPath, mountAs, accessMode, &configmap) + if !isAllowedToMount(&configmap, automountCM, workspaceDeployment) { + continue + } + + if msg := checkAutomountVolumeForPotentialError(&configmap); msg != "" { + return nil, &dwerrors.FailError{Message: msg} + } + + allAutoMountResources = append(allAutoMountResources, automountCM) } - automountResources := flattenAutomountResources(allAutoMountResouces) + + automountResources := flattenAutomountResources(allAutoMountResources) return &automountResources, nil } diff --git a/pkg/provision/automount/gitconfig.go b/pkg/provision/automount/gitconfig.go index a9648a05e..453b74ac8 100644 --- a/pkg/provision/automount/gitconfig.go +++ b/pkg/provision/automount/gitconfig.go @@ -1,4 +1,4 @@ -// 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 @@ -14,20 +14,27 @@ package automount import ( - "github.com/devfile/devworkspace-operator/pkg/constants" - "github.com/devfile/devworkspace-operator/pkg/dwerrors" - "github.com/devfile/devworkspace-operator/pkg/provision/sync" + "github.com/devfile/devworkspace-operator/pkg/common" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/provision/sync" ) const mergedGitCredentialsMountPath = "/.git-credentials/" // ProvisionGitConfiguration takes care of mounting git credentials and a gitconfig into a devworkspace. -func ProvisionGitConfiguration(api sync.ClusterAPI, namespace string) (*Resources, error) { - credentialsSecrets, tlsConfigMaps, err := getGitResources(api, namespace) +func ProvisionGitConfiguration( + api sync.ClusterAPI, + namespace string, + workspaceDeployment *appsv1.Deployment, +) (*Resources, error) { + credentialsSecrets, tlsConfigMaps, err := getGitResources(api, namespace, workspaceDeployment) if err != nil { return nil, err } @@ -69,7 +76,11 @@ func ProvisionGitConfiguration(api sync.ClusterAPI, namespace string) (*Resource return &resources, nil } -func getGitResources(api sync.ClusterAPI, namespace string) (credentialSecrets []corev1.Secret, tlsConfigMaps []corev1.ConfigMap, err error) { +func getGitResources( + api sync.ClusterAPI, + namespace string, + workspaceDeployment *appsv1.Deployment, +) (credentialSecrets []corev1.Secret, tlsConfigMaps []corev1.ConfigMap, err error) { credentialsLabelSelector := k8sclient.MatchingLabels{ constants.DevWorkspaceGitCredentialLabel: "true", } @@ -82,8 +93,10 @@ func getGitResources(api sync.ClusterAPI, namespace string) (credentialSecrets [ return nil, nil, err } var secrets []corev1.Secret - if len(secretList.Items) > 0 { - secrets = secretList.Items + if isGitCredentialsAllowedToMount(secretList.Items, workspaceDeployment) { + if len(secretList.Items) > 0 { + secrets = secretList.Items + } } sortSecrets(secrets) @@ -92,8 +105,12 @@ func getGitResources(api sync.ClusterAPI, namespace string) (credentialSecrets [ return nil, nil, err } var configmaps []corev1.ConfigMap - if len(configmapList.Items) > 0 { - configmaps = configmapList.Items + // When git credentials are present, the gitconfig ConfigMap must be created + // regardless of mount-on-start annotations. + if len(secrets) > 0 || isGitConfigsAllowedToMount(configmapList.Items, workspaceDeployment) { + if len(configmapList.Items) > 0 { + configmaps = configmapList.Items + } } sortConfigmaps(configmaps) @@ -139,3 +156,51 @@ func cleanupGitConfig(api sync.ClusterAPI, namespace string) error { return nil } + +func isGitCredentialsAllowedToMount(secrets []corev1.Secret, workspaceDeployment *appsv1.Deployment) bool { + volumeName := common.AutoMountSecretVolumeName(constants.GitCredentialsMergedSecretName) + return isGitObjectsAllowedToMount(secrets, volumeName, workspaceDeployment) +} + +func isGitConfigsAllowedToMount(configMaps []corev1.ConfigMap, workspaceDeployment *appsv1.Deployment) bool { + volumeName := common.AutoMountConfigMapVolumeName(constants.GitCredentialsConfigMapName) + return isGitObjectsAllowedToMount(configMaps, volumeName, workspaceDeployment) +} + +func isGitObjectsAllowedToMount[T any](objs []T, volumeName string, workspaceDeployment *appsv1.Deployment) bool { + // No deployment exists yet — workspace is not running, no restart risk + if workspaceDeployment == nil { + return true + } + + // At least one object lacks mount-on-start + if !allItemsMountOnStart(objs) { + return true + } + + automountResource := Resources{Volumes: []corev1.Volume{{Name: volumeName}}} + + // Volume is already mounted in the deployment, updating it won't cause a restart + if isVolumeMountExistsInDeployment(automountResource, workspaceDeployment) { + return true + } + + return false +} + +func allItemsMountOnStart[T any](objs []T) bool { + for i := range objs { + var obj interface{} = &objs[i] + + k8sObj, ok := obj.(k8sclient.Object) + if !ok { + continue + } + + if !isMountOnStart(k8sObj) { + return false + } + } + + return true +} diff --git a/pkg/provision/automount/gitconfig_test.go b/pkg/provision/automount/gitconfig_test.go index 233dd588a..7a988fdbe 100644 --- a/pkg/provision/automount/gitconfig_test.go +++ b/pkg/provision/automount/gitconfig_test.go @@ -1,4 +1,4 @@ -// 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 @@ -19,9 +19,11 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/devfile/devworkspace-operator/pkg/constants" "github.com/devfile/devworkspace-operator/pkg/provision/sync" - "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -52,7 +54,7 @@ func TestUserCredentialsAreMountedWithOneCredential(t *testing.T) { // ProvisionGitConfiguration has to be called multiple times since it stops after creating each configmap/secret ok := assert.Eventually(t, func() bool { var err error - resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace) + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, nil) t.Log(err) return err == nil }, 100*time.Millisecond, 10*time.Millisecond) @@ -78,7 +80,7 @@ func TestUserCredentialsAreOnlyMountedOnceWithMultipleCredentials(t *testing.T) // ProvisionGitConfiguration has to be called multiple times since it stops after creating each configmap/secret ok := assert.Eventually(t, func() bool { var err error - resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace) + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, nil) t.Log(err) return err == nil }, 100*time.Millisecond, 10*time.Millisecond) @@ -98,7 +100,7 @@ func TestGitConfigIsFullyMounted(t *testing.T) { // ProvisionGitConfiguration has to be called multiple times since it stops after creating each configmap/secret ok := assert.Eventually(t, func() bool { var err error - resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace) + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, nil) t.Log(err) return err == nil }, 100*time.Millisecond, 10*time.Millisecond) @@ -218,6 +220,208 @@ func TestTwoConfigMapWithBothMissingHost(t *testing.T) { assert.Equal(t, err.Error(), "multiple git tls credentials do not have host specified") } +func TestShouldNotMergeGitCredentialWhenSecretWithMountOnStartIfWorkspaceStarted(t *testing.T) { + mountPath := "/sample/test" + testSecret := buildSecretWithAnnotations("test-secret", mountPath, map[string][]byte{ + gitCredentialsSecretKey: []byte("my_credentials"), + }, map[string]string{ + constants.MountOnStartAttribute: "true", + }) + + clusterAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(&testSecret).Build(), + Logger: zap.New(), + } + + var resources *Resources + ok := assert.Eventually(t, func() bool { + var err error + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, emptyDeployment()) + t.Log(err) + return err == nil + }, 100*time.Millisecond, 10*time.Millisecond) + if ok { + assert.Nil(t, resources) + } +} + +func TestMountGitCredentialWhenSecretWithMountOnStartIfWorkspaceNotStarted(t *testing.T) { + mountPath := "/sample/test" + // Create a secret with mount-on-start-only annotation + testSecret := buildSecretWithAnnotations("test-secret", mountPath, map[string][]byte{ + gitCredentialsSecretKey: []byte("my_credentials"), + }, map[string]string{ + constants.MountOnStartAttribute: "true", + }) + + clusterAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(&testSecret).Build(), + } + + var resources *Resources + ok := assert.Eventually(t, func() bool { + var err error + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, nil) + t.Log(err) + return err == nil + }, 100*time.Millisecond, 10*time.Millisecond) + if ok { + // devworkspace-gitconfig + // devworkspace-merged-git-credentials + assert.Len(t, resources.Volumes, 2) + assert.Len(t, resources.VolumeMounts, 2) + } +} + +func TestShouldNotIncludeGitTLSConfigMapWithMountOnStartIfWorkspaceStarted(t *testing.T) { + mountPath := "/sample/test" + testConfigMap := buildConfigWithAnnotations("test-cm", mountPath, defaultData, map[string]string{ + constants.MountOnStartAttribute: "true", + }) + + clusterAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(&testConfigMap).Build(), + Logger: zap.New(), + } + + var resources *Resources + ok := assert.Eventually(t, func() bool { + var err error + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, emptyDeployment()) + t.Log(err) + return err == nil + }, 100*time.Millisecond, 10*time.Millisecond) + if ok { + assert.Nil(t, resources) + } +} + +func TestMountGitCredentialWhenConfigMapWithMountOnStartIfWorkspaceNotStarted(t *testing.T) { + mountPath := "/sample/test" + // Create a configmap with mount-on-start-only annotation + testConfigMap := buildConfigWithAnnotations("test-cm", mountPath, defaultData, map[string]string{ + constants.MountOnStartAttribute: "true", + }) + + clusterAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(&testConfigMap).Build(), + Logger: zap.New(), + } + + var resources *Resources + ok := assert.Eventually(t, func() bool { + var err error + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, nil) + t.Log(err) + return err == nil + }, 100*time.Millisecond, 10*time.Millisecond) + if ok { + assert.Len(t, resources.Volumes, 2) + assert.Len(t, resources.VolumeMounts, 2) + } +} + +func TestMountGitCredentialSecretWithMountOnStartWhenVolumeExistsInDeployment(t *testing.T) { + mountPath := "/sample/test" + testSecret := buildSecretWithAnnotations("test-secret", mountPath, map[string][]byte{ + gitCredentialsSecretKey: []byte("my_credentials"), + }, map[string]string{ + constants.MountOnStartAttribute: "true", + }) + + clusterAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(&testSecret).Build(), + Logger: zap.New(), + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test-container"}}, + Volumes: []corev1.Volume{{Name: constants.GitCredentialsMergedSecretName}}, + }, + }, + }, + } + + var resources *Resources + ok := assert.Eventually(t, func() bool { + var err error + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, deployment) + t.Log(err) + return err == nil + }, 100*time.Millisecond, 10*time.Millisecond) + if ok { + assert.Len(t, resources.Volumes, 2) + assert.Len(t, resources.VolumeMounts, 2) + } +} + +func TestMountGitTLSConfigMapWithMountOnStartWhenVolumeExistsInDeployment(t *testing.T) { + mountPath := "/sample/test" + testConfigMap := buildConfigWithAnnotations("test-cm", mountPath, defaultData, map[string]string{ + constants.MountOnStartAttribute: "true", + }) + + clusterAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(&testConfigMap).Build(), + Logger: zap.New(), + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "test-container"}}, + Volumes: []corev1.Volume{{Name: constants.GitCredentialsConfigMapName}}, + }, + }, + }, + } + + var resources *Resources + ok := assert.Eventually(t, func() bool { + var err error + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, deployment) + t.Log(err) + return err == nil + }, 100*time.Millisecond, 10*time.Millisecond) + if ok { + assert.Len(t, resources.Volumes, 2) + assert.Len(t, resources.VolumeMounts, 2) + } +} + +func TestMountGitCredentialWhenMixedMountOnStartSecrets(t *testing.T) { + mountPath := "/sample/test" + secretWithMountOnStart := buildSecretWithAnnotations("test-secret-1", mountPath, map[string][]byte{ + gitCredentialsSecretKey: []byte("my_credentials_1"), + }, map[string]string{ + constants.MountOnStartAttribute: "true", + }) + secretWithoutMountOnStart := buildSecret("test-secret-2", mountPath, map[string][]byte{ + gitCredentialsSecretKey: []byte("my_credentials_2"), + }) + + clusterAPI := sync.ClusterAPI{ + Client: fake.NewClientBuilder().WithObjects(&secretWithMountOnStart, &secretWithoutMountOnStart).Build(), + Logger: zap.New(), + } + + var resources *Resources + ok := assert.Eventually(t, func() bool { + var err error + resources, err = ProvisionGitConfiguration(clusterAPI, testNamespace, emptyDeployment()) + t.Log(err) + return err == nil + }, 100*time.Millisecond, 10*time.Millisecond) + if ok { + assert.Len(t, resources.Volumes, 2) + assert.Len(t, resources.VolumeMounts, 2) + } +} + func buildConfig(name string, mountPath string, data map[string]string) corev1.ConfigMap { return corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -249,3 +453,19 @@ func buildSecret(name string, mountPath string, data map[string][]byte) corev1.S Data: data, } } + +func buildSecretWithAnnotations(name string, mountPath string, data map[string][]byte, annotations map[string]string) corev1.Secret { + secret := buildSecret(name, mountPath, data) + for k, v := range annotations { + secret.Annotations[k] = v + } + return secret +} + +func buildConfigWithAnnotations(name string, mountPath string, data map[string]string, annotations map[string]string) corev1.ConfigMap { + cm := buildConfig(name, mountPath, data) + for k, v := range annotations { + cm.Annotations[k] = v + } + return cm +} diff --git a/pkg/provision/automount/pvcs.go b/pkg/provision/automount/pvcs.go index a63c68987..76390fb51 100644 --- a/pkg/provision/automount/pvcs.go +++ b/pkg/provision/automount/pvcs.go @@ -21,6 +21,7 @@ import ( "path" "strings" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -61,7 +62,11 @@ func parseMountPathAnnotation(annotation string, pvcName string) ([]mountPathEnt return entries, nil } -func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) { +func getAutoMountPVCs( + namespace string, + api sync.ClusterAPI, + workspaceDeployment *appsv1.Deployment, +) (*Resources, error) { pvcs := &corev1.PersistentVolumeClaimList{} if err := api.Client.List(api.Ctx, pvcs, k8sclient.InNamespace(namespace), k8sclient.MatchingLabels{ constants.DevWorkspaceMountLabel: "true", @@ -72,12 +77,11 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) return nil, nil } - var volumes []corev1.Volume - var volumeMounts []corev1.VolumeMount + var allAutoMountResources []Resources for _, pvc := range pvcs.Items { mountReadOnly := pvc.Annotations[constants.DevWorkspaceMountReadyOnlyAnnotation] == "true" - volumes = append(volumes, corev1.Volume{ + volume := corev1.Volume{ Name: common.AutoMountPVCVolumeName(pvc.Name), VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ @@ -85,13 +89,14 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) ReadOnly: mountReadOnly, }, }, - }) + } mountPathEntries, err := parseMountPathAnnotation(pvc.Annotations[constants.DevWorkspaceMountPathAnnotation], pvc.Name) if err != nil { return nil, err } + var volumeMounts []corev1.VolumeMount for _, entry := range mountPathEntries { volumeMounts = append(volumeMounts, corev1.VolumeMount{ Name: common.AutoMountPVCVolumeName(pvc.Name), @@ -99,9 +104,19 @@ func getAutoMountPVCs(namespace string, api sync.ClusterAPI) (*Resources, error) SubPath: entry.SubPath, }) } + + automountPVC := Resources{ + Volumes: []corev1.Volume{volume}, + VolumeMounts: volumeMounts, + } + + if !isAllowedToMount(&pvc, automountPVC, workspaceDeployment) { + continue + } + + allAutoMountResources = append(allAutoMountResources, automountPVC) } - return &Resources{ - Volumes: volumes, - VolumeMounts: volumeMounts, - }, nil + + automountResources := flattenAutomountResources(allAutoMountResources) + return &automountResources, nil } diff --git a/pkg/provision/automount/secret.go b/pkg/provision/automount/secret.go index 743e008d7..5c6f07585 100644 --- a/pkg/provision/automount/secret.go +++ b/pkg/provision/automount/secret.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 @@ -20,16 +20,17 @@ import ( "path" "sort" - "github.com/devfile/devworkspace-operator/pkg/common" - "github.com/devfile/devworkspace-operator/pkg/dwerrors" - "github.com/devfile/devworkspace-operator/pkg/provision/sync" + appsv1 "k8s.io/api/apps/v1" 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/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/provision/sync" ) -func getDevWorkspaceSecrets(namespace string, api sync.ClusterAPI) (*Resources, error) { +func getDevWorkspaceSecrets(namespace string, api sync.ClusterAPI, workspaceDeployment *appsv1.Deployment) (*Resources, error) { secrets := &corev1.SecretList{} if err := api.Client.List(api.Ctx, secrets, k8sclient.InNamespace(namespace), k8sclient.MatchingLabels{ constants.DevWorkspaceMountLabel: "true", @@ -37,11 +38,8 @@ func getDevWorkspaceSecrets(namespace string, api sync.ClusterAPI) (*Resources, return nil, err } sortSecrets(secrets.Items) - var allAutoMountResouces []Resources + var allAutoMountResources []Resources for _, secret := range secrets.Items { - if msg := checkAutomountVolumeForPotentialError(&secret); msg != "" { - return nil, &dwerrors.FailError{Message: msg} - } mountAs := secret.Annotations[constants.DevWorkspaceMountAsAnnotation] mountPath := secret.Annotations[constants.DevWorkspaceMountPathAnnotation] if mountPath == "" { @@ -55,9 +53,18 @@ func getDevWorkspaceSecrets(namespace string, api sync.ClusterAPI) (*Resources, } } - allAutoMountResouces = append(allAutoMountResouces, getAutomountSecret(mountPath, mountAs, accessMode, &secret)) + automountSecret := getAutomountSecret(mountPath, mountAs, accessMode, &secret) + if !isAllowedToMount(&secret, automountSecret, workspaceDeployment) { + continue + } + + if msg := checkAutomountVolumeForPotentialError(&secret); msg != "" { + return nil, &dwerrors.FailError{Message: msg} + } + + allAutoMountResources = append(allAutoMountResources, automountSecret) } - automountResources := flattenAutomountResources(allAutoMountResouces) + automountResources := flattenAutomountResources(allAutoMountResources) return &automountResources, nil } diff --git a/pkg/provision/workspace/deployment.go b/pkg/provision/workspace/deployment.go index 7b51e0a48..d9a969ec9 100644 --- a/pkg/provision/workspace/deployment.go +++ b/pkg/provision/workspace/deployment.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 @@ -396,3 +396,18 @@ func getAdditionalDeploymentAnnotations(workspace *common.DevWorkspaceWithConfig return annotations, nil } + +func GetClusterDeployment(workspace *common.DevWorkspaceWithConfig, clusterAPI sync.ClusterAPI) (*appsv1.Deployment, error) { + workspaceDeployment := &appsv1.Deployment{} + workspaceKey := types.NamespacedName{Name: common.DeploymentName(workspace.Status.DevWorkspaceId), Namespace: workspace.Namespace} + + err := clusterAPI.Client.Get(clusterAPI.Ctx, workspaceKey, workspaceDeployment) + if err != nil { + if k8sErrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + + return workspaceDeployment, nil +}