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/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..c0f1e44ad 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 _, automountVolumes := range automountResource.Volumes { + for _, deploymentVolumes := range workspaceDeployment.Spec.Template.Spec.Volumes { + if automountVolumes.Name == deploymentVolumes.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..072cc7ab6 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,435 @@ 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, "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, "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, "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, "test-cm", testPodAdditions.Volumes[0].Name) +} + +func TestShouldNotMountSecretWithMountOnStartWhenRunningAndNotInDeployment(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 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, "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, "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..2facc5a4b 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" + v1 "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 *v1.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,14 @@ 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 +59,15 @@ 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 + } + + 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..c996df5de 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,19 +14,25 @@ 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" + 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) { +func ProvisionGitConfiguration( + api sync.ClusterAPI, + namespace string, + workspaceDeployment *appsv1.Deployment, +) (*Resources, error) { credentialsSecrets, tlsConfigMaps, err := getGitResources(api, namespace) if err != nil { return nil, err @@ -43,12 +49,12 @@ func ProvisionGitConfiguration(api sync.ClusterAPI, namespace string) (*Resource return nil, err } - mergedCredentialsSecret, err := mergeGitCredentials(namespace, credentialsSecrets) + mergedCredentialsSecret, err := mergeGitCredentials(namespace, credentialsSecrets, workspaceDeployment) if err != nil { return nil, &dwerrors.FailError{Message: "Failed to collect git credentials secrets", Err: err} } - gitConfigMap, err := constructGitConfig(namespace, mergedGitCredentialsMountPath, tlsConfigMaps, baseGitConfig) + gitConfigMap, err := constructGitConfig(namespace, mergedGitCredentialsMountPath, tlsConfigMaps, baseGitConfig, workspaceDeployment) if err != nil { return nil, &dwerrors.FailError{Message: "Failed to prepare git config for workspace", Err: err} } diff --git a/pkg/provision/automount/gitconfig_test.go b/pkg/provision/automount/gitconfig_test.go index 233dd588a..d0fc55fe2 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,11 +19,14 @@ 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" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/log/zap" ) @@ -52,7 +55,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 +81,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 +101,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) @@ -114,7 +117,7 @@ func TestOneConfigMapWithNoUserMountPath(t *testing.T) { buildConfig(defaultName, mountPath, defaultData), } - gitconfig, err := constructGitConfig(testNamespace, mountPath, configmaps, nil) + gitconfig, err := constructGitConfig(testNamespace, mountPath, configmaps, nil, nil) if !assert.NoError(t, err, "Should not return error") { return } @@ -127,7 +130,7 @@ func TestOneConfigMapWithMountPathAndHostAndCert(t *testing.T) { buildConfig(defaultName, mountPath, defaultData), } - gitconfig, err := constructGitConfig(testNamespace, mountPath, configmaps, nil) + gitconfig, err := constructGitConfig(testNamespace, mountPath, configmaps, nil, nil) if !assert.NoError(t, err, "Should not return error") { return } @@ -140,7 +143,7 @@ func TestOneConfigMapWithMountPathAndWithoutHostAndWithoutCert(t *testing.T) { buildConfig(defaultName, mountPath, map[string]string{}), } - _, err := constructGitConfig(testNamespace, mountPath, configmaps, nil) + _, err := constructGitConfig(testNamespace, mountPath, configmaps, nil, nil) assert.Equal(t, err.Error(), fmt.Sprintf("could not find certificate field in configmap %s", defaultName)) } @@ -152,7 +155,7 @@ func TestOneConfigMapWithMountPathAndWithoutHostAndWithCert(t *testing.T) { }), } - gitconfig, err := constructGitConfig(testNamespace, mountPath, configmaps, nil) + gitconfig, err := constructGitConfig(testNamespace, mountPath, configmaps, nil, nil) if !assert.NoError(t, err, "Should not return error") { return } @@ -167,7 +170,7 @@ func TestOneConfigMapWithMountPathAndWithHostAndWithoutCert(t *testing.T) { }), } - _, err := constructGitConfig(testNamespace, mountPath, configmaps, nil) + _, err := constructGitConfig(testNamespace, mountPath, configmaps, nil, nil) assert.Equal(t, err.Error(), fmt.Sprintf("could not find certificate field in configmap %s", defaultName)) } @@ -177,7 +180,7 @@ func TestTwoConfigMapWithNoDefinedMountPathInAnnotation(t *testing.T) { buildConfig("configmap2", "/folder2", defaultData), } - gitconfig, err := constructGitConfig(testNamespace, "", configmaps, nil) + gitconfig, err := constructGitConfig(testNamespace, "", configmaps, nil, nil) if !assert.NoError(t, err, "Should not return error") { return } @@ -196,7 +199,7 @@ func TestTwoConfigMapWithOneDefaultTLSAndOtherGithubTLS(t *testing.T) { }), } - gitconfig, err := constructGitConfig(testNamespace, "", configmaps, nil) + gitconfig, err := constructGitConfig(testNamespace, "", configmaps, nil, nil) if !assert.NoError(t, err, "Should not return error") { return } @@ -214,10 +217,220 @@ func TestTwoConfigMapWithBothMissingHost(t *testing.T) { }), } - _, err := constructGitConfig(testNamespace, "", configmaps, nil) + _, err := constructGitConfig(testNamespace, "", configmaps, nil, nil) 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.NotNil(t, resources) + mergedSecret := &corev1.Secret{} + err := clusterAPI.Client.Get(clusterAPI.Ctx, types.NamespacedName{Name: constants.GitCredentialsMergedSecretName, Namespace: testNamespace}, mergedSecret) + assert.NoError(t, err) + assert.Empty(t, string(mergedSecret.Data[gitCredentialsSecretKey])) + } +} + +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.NotNil(t, resources) + gitConfigCM := &corev1.ConfigMap{} + err := clusterAPI.Client.Get(clusterAPI.Ctx, types.NamespacedName{Name: constants.GitCredentialsConfigMapName, Namespace: testNamespace}, gitConfigCM) + assert.NoError(t, err) + assert.NotContains(t, gitConfigCM.Data[gitConfigName], fmt.Sprintf(gitServerTemplate, defaultData[gitTLSHostKey], filepath.Join(mountPath, gitTLSCertificateKey))) + } +} + +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 +462,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..fdf0a5d71 100644 --- a/pkg/provision/automount/pvcs.go +++ b/pkg/provision/automount/pvcs.go @@ -21,6 +21,7 @@ import ( "path" "strings" + v1 "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 *v1.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..f06bb4d6c 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" + v1 "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 *v1.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,12 @@ 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 +57,14 @@ 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 + } + + allAutoMountResources = append(allAutoMountResources, automountSecret) } - automountResources := flattenAutomountResources(allAutoMountResouces) + automountResources := flattenAutomountResources(allAutoMountResources) return &automountResources, nil } diff --git a/pkg/provision/automount/templates.go b/pkg/provision/automount/templates.go index d725de023..6f7723be2 100644 --- a/pkg/provision/automount/templates.go +++ b/pkg/provision/automount/templates.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 @@ -16,11 +16,14 @@ package automount import ( "fmt" "path" + "slices" "strings" "github.com/devfile/devworkspace-operator/pkg/constants" + 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" ) const gitTLSHostKey = "host" @@ -57,7 +60,12 @@ const defaultGitServerTemplate = `[http] sslCAInfo = %s ` -func constructGitConfig(namespace, credentialMountPath string, certificatesConfigMaps []corev1.ConfigMap, baseGitConfig *string) (*corev1.ConfigMap, error) { +func constructGitConfig( + namespace, credentialMountPath string, + certificatesConfigMaps []corev1.ConfigMap, + baseGitConfig *string, + workspaceDeployment *appsv1.Deployment, +) (*corev1.ConfigMap, error) { var configSettings []string configSettings = append(configSettings, gitLFSConfig) @@ -69,12 +77,35 @@ func constructGitConfig(namespace, credentialMountPath string, certificatesConfi configSettings = append(configSettings, *baseGitConfig) } + gitConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.GitCredentialsConfigMapName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/defaultName": "git-config-secret", + "app.kubernetes.io/part-of": "devworkspace-operator", + "controller.devfile.io/watch-configmap": "true", + }, + }, + } + + // automountCM is not returned — it is only used to check if the volume is already mounted into the workspace deployment. + // gitConfigMap.Data is not yet populated at this point. + automountCM := getAutomountConfigmap("/etc/", constants.DevWorkspaceMountAsSubpath, defaultAccessMode, gitConfigMap) + allConfigMapsMountOnStart := !slices.ContainsFunc(certificatesConfigMaps, func(cm corev1.ConfigMap) bool { + return !isMountOnStart(&cm) + }) + defaultTLSFound := false for _, cm := range certificatesConfigMaps { if _, certFound := cm.Data[gitTLSCertificateKey]; !certFound { return nil, fmt.Errorf("could not find certificate field in configmap %s", cm.Name) } + if !shouldIncludeGitObject(&cm, allConfigMapsMountOnStart, automountCM, workspaceDeployment) { + continue + } + mountPath := cm.Annotations[constants.DevWorkspaceMountPathAnnotation] if mountPath == "" { mountPath = fmt.Sprintf("/etc/config/%s", cm.Name) @@ -95,47 +126,71 @@ func constructGitConfig(namespace, credentialMountPath string, certificatesConfi gitConfig := strings.Join(configSettings, "\n") - gitConfigMap := &corev1.ConfigMap{ + gitConfigMap.Data = map[string]string{ + gitConfigName: gitConfig, + } + + return gitConfigMap, nil +} + +func mergeGitCredentials( + namespace string, + credentialSecrets []corev1.Secret, + workspaceDeployment *appsv1.Deployment, +) (*corev1.Secret, error) { + mergedCredentialsSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: constants.GitCredentialsConfigMapName, + Name: constants.GitCredentialsMergedSecretName, Namespace: namespace, Labels: map[string]string{ - "app.kubernetes.io/defaultName": "git-config-secret", - "app.kubernetes.io/part-of": "devworkspace-operator", - "controller.devfile.io/watch-configmap": "true", + "app.kubernetes.io/defaultName": "git-config-secret", + "app.kubernetes.io/part-of": "devworkspace-operator", + "controller.devfile.io/watch-secret": "true", }, }, - Data: map[string]string{ - gitConfigName: gitConfig, - }, + Type: corev1.SecretTypeOpaque, } - return gitConfigMap, nil -} + // automountSecret is not returned — it is only used to check if the volume is already mounted into + // the workspace deployment. mergedCredentialsSecret.Data is not yet populated at this point. + automountSecret := getAutomountSecret(mergedGitCredentialsMountPath, constants.DevWorkspaceMountAsFile, defaultAccessMode, mergedCredentialsSecret) + allSecretsMountOnStart := !slices.ContainsFunc(credentialSecrets, func(secret corev1.Secret) bool { + return !isMountOnStart(&secret) + }) -func mergeGitCredentials(namespace string, credentialSecrets []corev1.Secret) (*corev1.Secret, error) { var allCredentials []string for _, credentialSecret := range credentialSecrets { + if !shouldIncludeGitObject(&credentialSecret, allSecretsMountOnStart, automountSecret, workspaceDeployment) { + continue + } + credential, found := credentialSecret.Data[gitCredentialsSecretKey] if !found { return nil, fmt.Errorf("git-credentials secret %s does not contain data in key %s", credentialSecret.Name, gitCredentialsSecretKey) } allCredentials = append(allCredentials, string(credential)) } - mergedCredentials := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: constants.GitCredentialsMergedSecretName, - Namespace: namespace, - Labels: map[string]string{ - "app.kubernetes.io/defaultName": "git-config-secret", - "app.kubernetes.io/part-of": "devworkspace-operator", - "controller.devfile.io/watch-secret": "true", - }, - }, - Data: map[string][]byte{ - gitCredentialsSecretKey: []byte(strings.Join(allCredentials, "\n")), - }, - Type: corev1.SecretTypeOpaque, + + mergedCredentialsSecret.Data = map[string][]byte{ + gitCredentialsSecretKey: []byte(strings.Join(allCredentials, "\n")), } - return mergedCredentials, nil + + return mergedCredentialsSecret, nil +} + +// shouldIncludeGitObject checks if a git object can be included without restarting a running workspace. +func shouldIncludeGitObject( + gitObject client.Object, + allGitObjectsMountOnStart bool, + automountMergedResource Resources, + workspaceDeployment *appsv1.Deployment, +) bool { + // At least one object lacks mount-on-start, updating it won't cause a restart if already mounted + return !allGitObjectsMountOnStart || + // This specific object doesn't require mount-on-start, safe to include anytime + !isMountOnStart(gitObject) || + // No deployment exists yet — workspace is not running, no restart risk + workspaceDeployment == nil || + // Volume is already mounted in the deployment, updating it won't cause a restart + isVolumeMountExistsInDeployment(automountMergedResource, workspaceDeployment) } 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 +}