diff --git a/pkg/provision/workspace/routing.go b/pkg/provision/workspace/routing.go index df14d89aa..e0fec14f0 100644 --- a/pkg/provision/workspace/routing.go +++ b/pkg/provision/workspace/routing.go @@ -16,6 +16,8 @@ package workspace import ( + "context" + "fmt" "strings" "time" @@ -23,20 +25,96 @@ import ( "github.com/devfile/devworkspace-operator/pkg/dwerrors" "github.com/devfile/devworkspace-operator/pkg/provision/sync" + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" maputils "github.com/devfile/devworkspace-operator/internal/map" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) +func checkRoutingConflicts( + ctx context.Context, + c client.Client, + workspace *common.DevWorkspaceWithConfig, + reqLogger logr.Logger) error { + + // Collect all endpoint names from the current workspace into a set for efficient lookup. + workspaceEndpoints := map[string]bool{} + for _, component := range workspace.Spec.Template.Components { + if component.Container != nil { + for _, endpoint := range component.Container.Endpoints { + if endpoint.Exposure == "internal" || endpoint.Exposure == "public" { + endpointName := common.EndpointName(endpoint.Name) + workspaceEndpoints[endpointName] = true + } + } + } + } + + // If there are no endpoints to check, we can exit early. + if len(workspaceEndpoints) == 0 { + return nil + } + + // Check for conflicts with other DevWorkspaces in the same namespace. + devWorkspaceList := &dw.DevWorkspaceList{} + if err := c.List(ctx, devWorkspaceList, &client.ListOptions{Namespace: workspace.Namespace}); err != nil { + return err + } + + for _, otherWorkspace := range devWorkspaceList.Items { + if otherWorkspace.UID == workspace.UID { + continue // Skip the current workspace + } + if otherWorkspace.Status.Phase == dw.DevWorkspaceStatusRunning || otherWorkspace.Status.Phase == dw.DevWorkspaceStatusStarting { + for _, component := range otherWorkspace.Spec.Template.Components { + if component.Container != nil { + for _, endpoint := range component.Container.Endpoints { + endpointName := common.EndpointName(endpoint.Name) + if _, ok := workspaceEndpoints[endpointName]; ok { + return &dwerrors.FailError{ + Message: fmt.Sprintf("Endpoint name '%s' conflicts with an active workspace '%s' in the same namespace. Please choose a different endpoint name.", endpointName, otherWorkspace.Name), + } + } + } + } + } + } + } + + // Check for conflicts with existing services in the namespace that are not owned by this workspace. + serviceList := &corev1.ServiceList{} + if err := c.List(ctx, serviceList, &client.ListOptions{Namespace: workspace.Namespace}); err != nil { + return err + } + + for _, service := range serviceList.Items { + if _, ok := workspaceEndpoints[service.Name]; ok { + if ownerId, ok := service.Labels[constants.DevWorkspaceIDLabel]; !ok || ownerId != workspace.Status.DevWorkspaceId { + return fmt.Errorf("service '%s' already exists in this namespace and is not owned by this workspace, this may indicate an endpoint name conflict, please choose a different endpoint name", service.Name) + } + } + } + + return nil +} + func SyncRoutingToCluster( workspace *common.DevWorkspaceWithConfig, clusterAPI sync.ClusterAPI) (*v1alpha1.PodAdditions, map[string]v1alpha1.ExposedEndpointList, string, error) { + // Call the new conflict check function + if err := checkRoutingConflicts(clusterAPI.Ctx, clusterAPI.Client, workspace, clusterAPI.Logger); err != nil { + return nil, nil, "", err + } + specRouting, err := getSpecRouting(workspace, clusterAPI.Scheme) if err != nil { return nil, nil, "", err diff --git a/pkg/provision/workspace/routing_test.go b/pkg/provision/workspace/routing_test.go new file mode 100644 index 000000000..501a75da1 --- /dev/null +++ b/pkg/provision/workspace/routing_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2019-2025 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 +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package workspace + +import ( + "context" + "testing" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestCheckRoutingConflicts(t *testing.T) { + scheme := runtime.NewScheme() + _ = dw.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + log := logr.Discard() + + tests := []struct { + name string + workspace *common.DevWorkspaceWithConfig + existing []runtime.Object + expectErr bool + expectedMsg string + }{ + { + name: "No conflicts", + workspace: testWorkspace("test-ws", "test-ns", "test-uid", []string{"endpoint1"}), + existing: []runtime.Object{}, + expectErr: false, + }, + { + name: "Conflict with another running workspace", + workspace: testWorkspace("test-ws", "test-ns", "test-uid", []string{"endpoint1"}), + existing: []runtime.Object{ + testWorkspace("other-ws", "test-ns", "other-uid", []string{"endpoint1"}).DevWorkspace, + }, + expectErr: true, + expectedMsg: "Endpoint name 'endpoint1' conflicts with an active workspace 'other-ws' in the same namespace. Please choose a different endpoint name.", + }, + { + name: "Conflict with an existing service", + workspace: testWorkspace("test-ws", "test-ns", "test-uid", []string{"endpoint1"}), + existing: []runtime.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "endpoint1", + Namespace: "test-ns", + }, + }, + }, + expectErr: true, + expectedMsg: "service 'endpoint1' already exists in this namespace and is not owned by this workspace, this may indicate an endpoint name conflict, please choose a different endpoint name", + }, + { + name: "No conflict with service owned by the same workspace", + workspace: testWorkspace("test-ws", "test-ns", "test-uid", []string{"endpoint1"}), + existing: []runtime.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "endpoint1", + Namespace: "test-ns", + Labels: map[string]string{ + constants.DevWorkspaceIDLabel: "test-ws-id", + }, + }, + }, + }, + expectErr: false, + }, + { + name: "No conflict with workspace in another namespace", + workspace: testWorkspace("test-ws", "test-ns", "test-uid", []string{"endpoint1"}), + existing: []runtime.Object{ + testWorkspace("other-ws", "other-ns", "other-uid", []string{"endpoint1"}).DevWorkspace, + }, + expectErr: false, + }, + { + name: "No conflict with stopped workspace", + workspace: testWorkspace("test-ws", "test-ns", "test-uid", []string{"endpoint1"}), + existing: []runtime.Object{ + func() *dw.DevWorkspace { + ws := testWorkspace("other-ws", "test-ns", "other-uid", []string{"endpoint1"}).DevWorkspace + ws.Status.Phase = dw.DevWorkspaceStatusStopped + return ws + }(), + }, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.existing...).Build() + err := checkRoutingConflicts(context.Background(), fakeClient, tt.workspace, log) + + if tt.expectErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func testWorkspace(name, namespace, uid string, endpoints []string) *common.DevWorkspaceWithConfig { + dwEndpoints := []dw.Endpoint{} + for _, e := range endpoints { + dwEndpoints = append(dwEndpoints, dw.Endpoint{ + Name: e, + TargetPort: 8080, + Exposure: dw.PublicEndpointExposure, + }) + } + return &common.DevWorkspaceWithConfig{ + DevWorkspace: &dw.DevWorkspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID(uid), + }, + Spec: dw.DevWorkspaceSpec{ + Template: dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Components: []dw.Component{ + { + Name: "test-component", + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Endpoints: dwEndpoints, + }, + }, + }, + }, + }, + }, + }, + Status: dw.DevWorkspaceStatus{ + Phase: dw.DevWorkspaceStatusRunning, + DevWorkspaceId: name + "-id", + }, + }, + } +}