diff --git a/controllers/devworkspace/solver/che_routing.go b/controllers/devworkspace/solver/che_routing.go index 4ef53e6b5c..e6b3681879 100644 --- a/controllers/devworkspace/solver/che_routing.go +++ b/controllers/devworkspace/solver/che_routing.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // This program and the accompanying materials are made // available under the terms of the Eclipse Public License 2.0 // which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -76,7 +76,11 @@ func (c *CheRoutingSolver) cheSpecObjects(cheCluster *chev2.CheCluster, routing } func (c *CheRoutingSolver) provisionServices(objs *solvers.RoutingObjects, cheCluster *chev2.CheCluster, routing *dwo.DevWorkspaceRouting, workspaceMeta solvers.DevWorkspaceMetadata) error { - objs.Services = solvers.GetDiscoverableServicesForEndpoints(routing.Spec.Endpoints, workspaceMeta) + services, err := solvers.GetDiscoverableServicesForEndpoints(routing.Spec.Endpoints, workspaceMeta, c.client) + if err != nil { + return fmt.Errorf("failed to generate discoverable Services for DevWorkspaceRouting %s/%s: %w", routing.Namespace, routing.Name, err) + } + objs.Services = services commonService := &corev1.Service{ ObjectMeta: v1.ObjectMeta{ diff --git a/controllers/devworkspace/solver/che_routing_test.go b/controllers/devworkspace/solver/che_routing_test.go index d6144e947f..05e673487f 100644 --- a/controllers/devworkspace/solver/che_routing_test.go +++ b/controllers/devworkspace/solver/che_routing_test.go @@ -14,6 +14,7 @@ package solver import ( "context" + "errors" "fmt" "strings" "testing" @@ -33,6 +34,7 @@ import ( apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/resource" 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" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -44,19 +46,13 @@ var ( ) func getSpecObjectsForManager(t *testing.T, mgr *chev2.CheCluster, routing *dwo.DevWorkspaceRouting, additionalInitialObjects ...client.Object) (client.Client, solvers.RoutingSolver, solvers.RoutingObjects) { - cheCtx := test.NewCtxBuilder().WithCheCluster(mgr).WithObjects(routing).WithObjects(additionalInitialObjects...).Build() - scheme := cheCtx.ClusterAPI.Scheme - cl := cheCtx.ClusterAPI.Client - - solver, err := Getter(scheme).GetSolver(cl, "che") - if err != nil { - t.Fatal(err) - } - + cl, scheme, solver := getSolverForManager(t, mgr, routing, additionalInitialObjects...) meta := solvers.DevWorkspaceMetadata{ - DevWorkspaceId: routing.Spec.DevWorkspaceId, - Namespace: routing.GetNamespace(), - PodSelector: routing.Spec.PodSelector, + DevWorkspaceId: routing.Spec.DevWorkspaceId, + DevWorkspaceName: routing.OwnerReferences[0].Name, + DevWorkspaceRoutingUID: routing.UID, + Namespace: routing.GetNamespace(), + PodSelector: routing.Spec.PodSelector, } objs, err := solver.GetSpecObjects(routing, meta) @@ -87,6 +83,20 @@ func getSpecObjectsForManager(t *testing.T, mgr *chev2.CheCluster, routing *dwo. return cl, solver, objs } +func getSolverForManager(t *testing.T, mgr *chev2.CheCluster, routing *dwo.DevWorkspaceRouting, additionalInitialObjects ...client.Object) (client.Client, *runtime.Scheme, solvers.RoutingSolver) { + t.Helper() + cheCtx := test.NewCtxBuilder().WithCheCluster(mgr).WithObjects(routing).WithObjects(additionalInitialObjects...).Build() + scheme := cheCtx.ClusterAPI.Scheme + cl := cheCtx.ClusterAPI.Client + + solver, err := Getter(scheme).GetSolver(cl, "che") + if err != nil { + t.Fatal(err) + } + + return cl, scheme, solver +} + func getSpecObjects(t *testing.T, routing *dwo.DevWorkspaceRouting) (client.Client, solvers.RoutingSolver, solvers.RoutingObjects) { return getSpecObjectsForManager(t, &chev2.CheCluster{ ObjectMeta: metav1.ObjectMeta{ @@ -102,6 +112,172 @@ func getSpecObjects(t *testing.T, routing *dwo.DevWorkspaceRouting) (client.Clie }, routing, userProfileSecret("username")) } +type serviceGetErrorClient struct { + client.Client + serviceName string + err error +} + +func (c serviceGetErrorClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, isService := obj.(*corev1.Service); isService && key.Name == c.serviceName { + return c.err + } + return c.Client.Get(ctx, key, obj, opts...) +} + +func getSpecObjectsResult(t *testing.T, routing *dwo.DevWorkspaceRouting, existingObjects []client.Object, getErr error) (client.Client, solvers.RoutingObjects, error) { + t.Helper() + cl, _, routingSolver := getSolverForManager(t, &chev2.CheCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "che", Namespace: "ns"}, + Spec: chev2.CheClusterSpec{Networking: chev2.CheClusterSpecNetworking{Domain: "down.on.earth", Hostname: "over.the.rainbow"}}, + }, routing, append([]client.Object{userProfileSecret("username")}, existingObjects...)...) + if getErr != nil { + discoverable := routing.Spec.Endpoints["m1"][0] + routingSolver.(*CheRoutingSolver).client = serviceGetErrorClient{ + Client: cl, + serviceName: dwCommon.EndpointName(discoverable.Name), + err: getErr, + } + } + meta := solvers.DevWorkspaceMetadata{ + DevWorkspaceId: routing.Spec.DevWorkspaceId, + DevWorkspaceName: routing.OwnerReferences[0].Name, + DevWorkspaceRoutingUID: routing.UID, + Namespace: routing.Namespace, + PodSelector: routing.Spec.PodSelector, + } + objs, err := routingSolver.GetSpecObjects(routing, meta) + return cl, objs, err +} + +func assertNoPartialRoutingObjects(t *testing.T, cl client.Client, objs solvers.RoutingObjects) { + t.Helper() + assert.Equal(t, solvers.RoutingObjects{}, objs) + configMaps := &corev1.ConfigMapList{} + assert.NoError(t, cl.List(context.Background(), configMaps)) + assert.Empty(t, configMaps.Items, "failed routing generation must not create gateway ConfigMaps") +} + +func TestGetSpecObjectsReturnsDiscoverableServiceErrors(t *testing.T) { + infrastructure.InitializeForTesting(infrastructure.Kubernetes) + + t.Run("foreign Service conflict", func(t *testing.T) { + routing := discoverableEndpointDevWorkspaceRouting() + original := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: dwCommon.EndpointName("e1"), + Namespace: routing.Namespace, + Labels: map[string]string{ + dwConstants.DevWorkspaceIDLabel: "another-workspace-id", + dwConstants.DevWorkspaceNameLabel: "another-workspace", + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Selector: map[string]string{"owner": "another-workspace"}, + Ports: []corev1.ServicePort{{Name: "foreign", Port: 1234}}, + }, + } + cl, objs, err := getSpecObjectsResult(t, routing, []client.Object{original}, nil) + var conflict *solvers.ServiceConflictError + assert.ErrorAs(t, err, &conflict) + if conflict != nil { + assert.Equal(t, "e1", conflict.EndpointName) + } + assertNoPartialRoutingObjects(t, cl, objs) + + got := &corev1.Service{} + if !assert.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(original), got)) { + return + } + assert.Equal(t, original, got) + }) + + t.Run("duplicate endpoint Service names", func(t *testing.T) { + routing := discoverableEndpointDevWorkspaceRouting() + routing.Spec.Endpoints = map[string]dwo.EndpointList{ + "m1": {{Name: "same--endpoint", TargetPort: 8080, Exposure: dwo.PublicEndpointExposure, Attributes: dwo.Attributes{string(dwo.DiscoverableAttribute): apiext.JSON{Raw: []byte(`"true"`)}}}}, + "m2": {{Name: "same-endpoint", TargetPort: 8081, Exposure: dwo.PublicEndpointExposure, Attributes: dwo.Attributes{string(dwo.DiscoverableAttribute): apiext.JSON{Raw: []byte(`"true"`)}}}}, + } + cl, objs, err := getSpecObjectsResult(t, routing, nil, nil) + var duplicate *solvers.DuplicateEndpointError + assert.ErrorAs(t, err, &duplicate) + if duplicate != nil { + assert.Contains(t, []string{"same--endpoint", "same-endpoint"}, duplicate.EndpointName) + } + assertNoPartialRoutingObjects(t, cl, objs) + }) + + t.Run("reserved aggregate Service name", func(t *testing.T) { + routing := discoverableEndpointDevWorkspaceRouting() + routing.Spec.Endpoints = map[string]dwo.EndpointList{ + "m1": {{Name: dwCommon.ServiceName(routing.Spec.DevWorkspaceId), TargetPort: 8080, Exposure: dwo.PublicEndpointExposure, Attributes: dwo.Attributes{string(dwo.DiscoverableAttribute): apiext.JSON{Raw: []byte(`"true"`)}}}}, + } + cl, objs, err := getSpecObjectsResult(t, routing, nil, nil) + var invalid *solvers.RoutingInvalid + assert.ErrorAs(t, err, &invalid) + assertNoPartialRoutingObjects(t, cl, objs) + }) + + t.Run("Service Get error keeps its cause", func(t *testing.T) { + routing := discoverableEndpointDevWorkspaceRouting() + cause := errors.New("injected Service read failure") + cl, objs, err := getSpecObjectsResult(t, routing, nil, cause) + if !assert.ErrorIs(t, err, cause) { + return + } + assert.Contains(t, err.Error(), "failed to generate discoverable Services for DevWorkspaceRouting ws/routing") + assertNoPartialRoutingObjects(t, cl, objs) + }) +} + +func TestDiscoverableServiceRoutingTargets(t *testing.T) { + for _, tc := range []struct { + name string + infra infrastructure.Type + }{ + {name: "Ingress", infra: infrastructure.Kubernetes}, + {name: "Route", infra: infrastructure.OpenShiftV4}, + } { + t.Run(tc.name, func(t *testing.T) { + infrastructure.InitializeForTesting(tc.infra) + routing := discoverableEndpointDevWorkspaceRouting() + endpoints := routing.Spec.Endpoints["m1"] + routing.Spec.Endpoints["m1"] = dwo.EndpointList{endpoints[0], endpoints[2]} + _, _, objs := getSpecObjects(t, routing) + + assert.Len(t, objs.Services, 2) + discoverableService := getEndpointService(&objs, endpoints[0]) + assert.NotNil(t, discoverableService) + if discoverableService != nil { + assert.Equal(t, int32(endpoints[0].TargetPort), discoverableService.Spec.Ports[0].Port) + } + assert.NotNil(t, getCommonService(&objs, routing.Spec.DevWorkspaceId)) + + for _, endpoint := range routing.Spec.Endpoints["m1"] { + expectedServiceName := dwCommon.ServiceName(routing.Spec.DevWorkspaceId) + if endpoint.Name == endpoints[0].Name { + expectedServiceName = dwCommon.EndpointName(endpoint.Name) + } + if tc.infra == infrastructure.OpenShiftV4 { + route := findRouteForEndpoint("m1", endpoint, &objs, routing.Spec.DevWorkspaceId) + if assert.NotNil(t, route, "Route missing for endpoint %s", endpoint.Name) { + assert.Equal(t, expectedServiceName, route.Spec.To.Name) + assert.Equal(t, endpoint.TargetPort, route.Spec.Port.TargetPort.IntValue()) + } + } else { + ingress := findIngressForEndpoint("m1", endpoint, &objs) + if assert.NotNil(t, ingress, "Ingress missing for endpoint %s", endpoint.Name) { + backend := ingress.Spec.Rules[0].HTTP.Paths[0].Backend.Service + assert.Equal(t, expectedServiceName, backend.Name) + assert.Equal(t, endpoint.TargetPort, int(backend.Port.Number)) + } + } + } + }) + } +} + func subdomainDevWorkspaceRouting() *dwo.DevWorkspaceRouting { return &dwo.DevWorkspaceRouting{ ObjectMeta: metav1.ObjectMeta{ @@ -198,6 +374,7 @@ func endpointAnnotationDevWorkspaceRouting() *dwo.DevWorkspaceRouting { func discoverableEndpointDevWorkspaceRouting() *dwo.DevWorkspaceRouting { return &dwo.DevWorkspaceRouting{ ObjectMeta: metav1.ObjectMeta{ + UID: "routing-uid", Name: "routing", Namespace: "ws", OwnerReferences: []metav1.OwnerReference{ diff --git a/go.mod b/go.mod index a2ed419855..9681927d4a 100644 --- a/go.mod +++ b/go.mod @@ -108,3 +108,5 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect ) + +replace github.com/devfile/devworkspace-operator => github.com/akurinnoy/devworkspace-operator v0.0.0-20261008102644-4d93c0891277 diff --git a/go.sum b/go.sum index 13d484ffe4..aa1df9cb4c 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1o github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= +github.com/akurinnoy/devworkspace-operator v0.0.0-20261008102644-4d93c0891277 h1:84WbnVz428i0RIefk3x3ifhhcr3Zc7nx47qw7A+2H58= +github.com/akurinnoy/devworkspace-operator v0.0.0-20261008102644-4d93c0891277/go.mod h1:+EIM8ujp9GO+S2w/eU3nAZv1hAkEZBrFmRQtg5PMVQA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -62,8 +64,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/devfile/api/v2 v2.3.1-alpha.0.20261007143616-3c7e2fd6d137 h1:bECxO1gOSmWNjqoTv+xUWSwzXGZhnCQOSJrlIVOWtR4= github.com/devfile/api/v2 v2.3.1-alpha.0.20261007143616-3c7e2fd6d137/go.mod h1:t+btHuWTpDnTPPf+MM4kB34Ebp5PVmEnn/nrhQXC4IQ= -github.com/devfile/devworkspace-operator v0.2.1-0.20261008221424-9e9bab4780a9 h1:E/c/MIbcMOB7ctfsUK7Ny/iBI95IjocZ8Ygol0qGNWI= -github.com/devfile/devworkspace-operator v0.2.1-0.20261008221424-9e9bab4780a9/go.mod h1:+EIM8ujp9GO+S2w/eU3nAZv1hAkEZBrFmRQtg5PMVQA= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= diff --git a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go index 1a4bbfd42f..6d3bb9118a 100644 --- a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go +++ b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/devworkspacerouting_controller.go @@ -21,6 +21,8 @@ import ( "fmt" "time" + dwv2 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers" maputils "github.com/devfile/devworkspace-operator/internal/map" "github.com/devfile/devworkspace-operator/pkg/config" @@ -34,7 +36,9 @@ import ( corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -54,8 +58,9 @@ const devWorkspaceRoutingFinalizer = "devworkspacerouting.controller.devfile.io" // DevWorkspaceRoutingReconciler reconciles a DevWorkspaceRouting object type DevWorkspaceRoutingReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme + NonCachingClient client.Client + Log logr.Logger + Scheme *runtime.Scheme // SolverGetter will be used to get solvers for a particular devWorkspaceRouting SolverGetter solvers.RoutingSolverGetter // Enable additional debug logging @@ -97,6 +102,7 @@ func (r *DevWorkspaceRoutingReconciler) Reconcile(ctx context.Context, req ctrl. solver, err := r.SolverGetter.GetSolver(r.Client, instance.Spec.RoutingClass) if err != nil { if errors.Is(err, solvers.RoutingNotSupported) { + reqLogger.Info("Routing class not supported by this controller, skipping reconciliation", "routingClass", instance.Spec.RoutingClass) return reconcile.Result{}, nil } return reconcile.Result{}, r.markRoutingFailed(instance, fmt.Sprintf("Invalid routingClass for DevWorkspace: %s", err)) @@ -126,9 +132,11 @@ func (r *DevWorkspaceRoutingReconciler) Reconcile(ctx context.Context, req ctrl. } workspaceMeta := solvers.DevWorkspaceMetadata{ - DevWorkspaceId: instance.Spec.DevWorkspaceId, - Namespace: instance.Namespace, - PodSelector: instance.Spec.PodSelector, + DevWorkspaceId: instance.Spec.DevWorkspaceId, + DevWorkspaceName: workspaceName(instance), + DevWorkspaceRoutingUID: instance.UID, + Namespace: instance.Namespace, + PodSelector: instance.Spec.PodSelector, } restrictedAccess, setRestrictedAccess := instance.Annotations[constants.DevWorkspaceRestrictedAccessAnnotation] @@ -150,6 +158,18 @@ func (r *DevWorkspaceRoutingReconciler) Reconcile(ctx context.Context, req ctrl. return reconcile.Result{}, r.markRoutingFailed(instance, fmt.Sprintf("Unable to provision networking for DevWorkspace: %s", invalid)) } + var conflict *solvers.ServiceConflictError + if errors.As(err, &conflict) { + reqLogger.Error(conflict, "Routing controller detected a service conflict", "endpointName", conflict.EndpointName, "workspaceName", conflict.WorkspaceName) + return reconcile.Result{}, r.markRoutingFailed(instance, fmt.Sprintf("Unable to provision networking for DevWorkspace: %s", conflict)) + } + + var duplicate *solvers.DuplicateEndpointError + if errors.As(err, &duplicate) { + reqLogger.Error(duplicate, "Routing controller detected a duplicate endpoint name", "endpointName", duplicate.EndpointName) + return reconcile.Result{}, r.markRoutingFailed(instance, fmt.Sprintf("Unable to provision networking for DevWorkspace: %s", duplicate)) + } + // generic error, just fail the reconciliation return reconcile.Result{}, err } @@ -241,6 +261,15 @@ func (r *DevWorkspaceRoutingReconciler) Reconcile(ctx context.Context, req ctrl. return reconcile.Result{}, r.reconcileStatus(instance, &routingObjects, exposedEndpoints, endpointsAreReady, "") } +// Generated by Codex. +func workspaceName(routing *controllerv1alpha1.DevWorkspaceRouting) string { + owner := metav1.GetControllerOf(routing) + if owner == nil || owner.Kind != "DevWorkspace" || schema.FromAPIVersionAndKind(owner.APIVersion, owner.Kind).Group != dwv2.SchemeGroupVersion.Group { + return "" + } + return owner.Name +} + // setFinalizer ensures a finalizer is set on a devWorkspaceRouting instance; no-op if finalizer is already present. func (r *DevWorkspaceRoutingReconciler) setFinalizer(reqLogger logr.Logger, solver solvers.RoutingSolver, m *controllerv1alpha1.DevWorkspaceRouting) error { if !solver.FinalizerRequired(m) || contains(m.GetFinalizers(), devWorkspaceRoutingFinalizer) { @@ -335,6 +364,14 @@ func remove(list []string, s string) []string { } func (r *DevWorkspaceRoutingReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.NonCachingClient == nil { + freshClient, err := client.New(mgr.GetConfig(), client.Options{Scheme: mgr.GetScheme()}) + if err != nil { + return err + } + r.NonCachingClient = freshClient + } + maxConcurrentReconciles, err := config.GetMaxConcurrentReconciles() if err != nil { return err diff --git a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/basic_solver.go b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/basic_solver.go index 50df27fa10..e7e6f40121 100644 --- a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/basic_solver.go +++ b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/basic_solver.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,6 +16,8 @@ package solvers import ( + "sigs.k8s.io/controller-runtime/pkg/client" + controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" "github.com/devfile/devworkspace-operator/pkg/config" "github.com/devfile/devworkspace-operator/pkg/constants" @@ -47,10 +49,19 @@ var nginxIngressAnnotations = func(endpointName string, endpointAnnotations map[ // According to the current cluster there is different behavior: // Kubernetes: use Ingresses without TLS // OpenShift: use Routes with TLS enabled -type BasicSolver struct{} +type BasicSolver struct { + client client.Client +} var _ RoutingSolver = (*BasicSolver)(nil) +// NewBasicSolver creates a new BasicSolver with the provided dependencies +func NewBasicSolver(client client.Client) *BasicSolver { + return &BasicSolver{ + client: client, + } +} + func (s *BasicSolver) FinalizerRequired(*controllerv1alpha1.DevWorkspaceRouting) bool { return false } @@ -70,7 +81,11 @@ func (s *BasicSolver) GetSpecObjects(routing *controllerv1alpha1.DevWorkspaceRou spec := routing.Spec services := getServicesForEndpoints(spec.Endpoints, workspaceMeta) - services = append(services, GetDiscoverableServicesForEndpoints(spec.Endpoints, workspaceMeta)...) + discoverableServices, err := GetDiscoverableServicesForEndpoints(spec.Endpoints, workspaceMeta, s.client) + if err != nil { + return RoutingObjects{}, err + } + services = append(services, discoverableServices...) routingObjects.Services = services if infrastructure.IsOpenShift() { routingObjects.Routes = getRoutesForSpec(routingSuffix, spec.Endpoints, workspaceMeta) diff --git a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/cluster_solver.go b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/cluster_solver.go index 7c54622524..901ab4c4be 100644 --- a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/cluster_solver.go +++ b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/cluster_solver.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 @@ -23,6 +23,8 @@ import ( corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" ) @@ -31,11 +33,20 @@ const ( ) type ClusterSolver struct { - TLS bool + TLS bool + client client.Client } var _ RoutingSolver = (*ClusterSolver)(nil) +// NewClusterSolver creates a new ClusterSolver with the provided dependencies +func NewClusterSolver(client client.Client, tls bool) *ClusterSolver { + return &ClusterSolver{ + TLS: tls, + client: client, + } +} + func (s *ClusterSolver) FinalizerRequired(*controllerv1alpha1.DevWorkspaceRouting) bool { return false } @@ -47,6 +58,11 @@ func (s *ClusterSolver) Finalize(*controllerv1alpha1.DevWorkspaceRouting) error func (s *ClusterSolver) GetSpecObjects(routing *controllerv1alpha1.DevWorkspaceRouting, workspaceMeta DevWorkspaceMetadata) (RoutingObjects, error) { spec := routing.Spec services := getServicesForEndpoints(spec.Endpoints, workspaceMeta) + discoverableServices, err := GetDiscoverableServicesForEndpoints(spec.Endpoints, workspaceMeta, s.client) + if err != nil { + return RoutingObjects{}, err + } + services = append(services, discoverableServices...) podAdditions := &controllerv1alpha1.PodAdditions{} if s.TLS { readOnlyMode := int32(420) @@ -64,10 +80,14 @@ func (s *ClusterSolver) GetSpecObjects(routing *controllerv1alpha1.DevWorkspaceR }, }, }) + mountPath := "/var/serving-cert/" + if service.Annotations[constants.DevWorkspaceDiscoverableServiceAnnotation] == "true" { + mountPath += service.Name + "/" + } podAdditions.VolumeMounts = append(podAdditions.VolumeMounts, corev1.VolumeMount{ Name: common.ServingCertVolumeName(service.Name), ReadOnly: true, - MountPath: "/var/serving-cert/", + MountPath: mountPath, }) } } diff --git a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/common.go b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/common.go index b55367ad8e..ac75d14853 100644 --- a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/common.go +++ b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/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 @@ -16,28 +16,37 @@ package solvers import ( - controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" - "github.com/devfile/devworkspace-operator/pkg/common" - "github.com/devfile/devworkspace-operator/pkg/constants" + "context" + "fmt" routeV1 "github.com/openshift/api/route/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" + + controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/constants" ) type DevWorkspaceMetadata struct { - DevWorkspaceId string - Namespace string - PodSelector map[string]string + DevWorkspaceId string + DevWorkspaceName string + DevWorkspaceRoutingUID types.UID + Namespace string + PodSelector map[string]string } // GetDiscoverableServicesForEndpoints converts the endpoint list into a set of services, each corresponding to a single discoverable // endpoint from the list. Endpoints with the NoneEndpointExposure are ignored. -func GetDiscoverableServicesForEndpoints(endpoints map[string]controllerv1alpha1.EndpointList, meta DevWorkspaceMetadata) []corev1.Service { +func GetDiscoverableServicesForEndpoints(endpoints map[string]controllerv1alpha1.EndpointList, meta DevWorkspaceMetadata, cl client.Client) ([]corev1.Service, error) { var services []corev1.Service + seenServiceNames := map[string]bool{} for _, machineEndpoints := range endpoints { for _, endpoint := range machineEndpoints { if endpoint.Exposure == controllerv1alpha1.NoneEndpointExposure { @@ -45,21 +54,49 @@ func GetDiscoverableServicesForEndpoints(endpoints map[string]controllerv1alpha1 } if endpoint.Attributes.GetBoolean(string(controllerv1alpha1.DiscoverableAttribute), nil) { - // Create service with name matching endpoint - // TODO: This could cause a reconcile conflict if multiple workspaces define the same discoverable endpoint - // Also endpoint names may not be valid as service names + serviceName := common.EndpointName(endpoint.Name) + if serviceName == common.ServiceName(meta.DevWorkspaceId) { + return nil, &RoutingInvalid{Reason: fmt.Sprintf("discoverable endpoint '%s' uses reserved Service name '%s'", endpoint.Name, serviceName)} + } + // Two endpoints on different containers can sanitize to the same Service name (e.g. "my--endpoint" and + // "my-endpoint"). Neither Service exists on the cluster yet at this point, so the existing-service check + // below can't catch this; track names claimed within this call instead. + if seenServiceNames[serviceName] { + return nil, &DuplicateEndpointError{EndpointName: endpoint.Name} + } + seenServiceNames[serviceName] = true + + existingService := &corev1.Service{} + err := cl.Get(context.TODO(), client.ObjectKey{Name: serviceName, Namespace: meta.Namespace}, existingService) + if err != nil { + if !errors.IsNotFound(err) { + return nil, err + } + } else { + controllerOwner := metav1.GetControllerOf(existingService) + ownedByRouting := controllerOwner != nil && controllerOwner.Kind == "DevWorkspaceRouting" && + meta.DevWorkspaceRoutingUID != "" && controllerOwner.UID == meta.DevWorkspaceRoutingUID + if !ownedByRouting && existingService.Labels[constants.DevWorkspaceIDLabel] != meta.DevWorkspaceId { + return nil, &ServiceConflictError{ + EndpointName: endpoint.Name, + WorkspaceName: existingService.Labels[constants.DevWorkspaceNameLabel], + } + } + } + servicePort := corev1.ServicePort{ - Name: common.EndpointName(endpoint.Name), + Name: serviceName, Protocol: corev1.ProtocolTCP, Port: int32(endpoint.TargetPort), TargetPort: intstr.FromInt(endpoint.TargetPort), } services = append(services, corev1.Service{ ObjectMeta: metav1.ObjectMeta{ - Name: common.EndpointName(endpoint.Name), + Name: serviceName, Namespace: meta.Namespace, Labels: map[string]string{ - constants.DevWorkspaceIDLabel: meta.DevWorkspaceId, + constants.DevWorkspaceIDLabel: meta.DevWorkspaceId, + constants.DevWorkspaceNameLabel: meta.DevWorkspaceName, }, Annotations: map[string]string{ constants.DevWorkspaceDiscoverableServiceAnnotation: "true", @@ -74,7 +111,7 @@ func GetDiscoverableServicesForEndpoints(endpoints map[string]controllerv1alpha1 } } } - return services + return services, nil } // GetServiceForEndpoints returns a single service that exposes all endpoints of given exposure types, possibly also including the discoverable types. diff --git a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/errors.go b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/errors.go index ffcdd7b7b5..386573e00c 100644 --- a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/errors.go +++ b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/errors.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 @@ -17,11 +17,30 @@ package solvers import ( "errors" + "fmt" "time" + + "github.com/devfile/devworkspace-operator/pkg/provision/sync" ) var _ error = (*RoutingNotReady)(nil) var _ error = (*RoutingInvalid)(nil) +var _ error = (*ServiceConflictError)(nil) +var _ error = (*DuplicateEndpointError)(nil) + +// ServiceConflictError is returned when a discoverable endpoint has a name that is already in use by +// another DevWorkspace's service. +type ServiceConflictError = sync.ServiceConflictError + +// DuplicateEndpointError is returned when a single DevWorkspace declares the same discoverable endpoint name - or +// two endpoint names that sanitize to the same Service name - on more than one container. +type DuplicateEndpointError struct { + EndpointName string +} + +func (e *DuplicateEndpointError) Error() string { + return fmt.Sprintf("discoverable endpoint '%s' is declared by more than one component in this workspace", e.EndpointName) +} // RoutingNotSupported is used by the solvers when they supported the routingclass of the workspace they've been asked to route var RoutingNotSupported = errors.New("routingclass not supported by this controller") diff --git a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/solver.go b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/solver.go index c5c5a5c877..fce842c351 100644 --- a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/solver.go +++ b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/solvers/solver.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 @@ -100,18 +100,19 @@ func (_ *SolverGetter) HasSolver(routingClass controllerv1alpha1.DevWorkspaceRou } } -func (_ *SolverGetter) GetSolver(_ client.Client, routingClass controllerv1alpha1.DevWorkspaceRoutingClass) (RoutingSolver, error) { +func (_ *SolverGetter) GetSolver(client client.Client, routingClass controllerv1alpha1.DevWorkspaceRoutingClass) (RoutingSolver, error) { isOpenShift := infrastructure.IsOpenShift() + switch routingClass { case controllerv1alpha1.DevWorkspaceRoutingBasic: - return &BasicSolver{}, nil + return NewBasicSolver(client), nil case controllerv1alpha1.DevWorkspaceRoutingCluster: - return &ClusterSolver{}, nil + return NewClusterSolver(client, false), nil case controllerv1alpha1.DevWorkspaceRoutingClusterTLS, controllerv1alpha1.DevWorkspaceRoutingWebTerminal: if !isOpenShift { return nil, fmt.Errorf("routing class %s only supported on OpenShift", routingClass) } - return &ClusterSolver{TLS: true}, nil + return NewClusterSolver(client, true), nil default: return nil, RoutingNotSupported } diff --git a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/sync_services.go b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/sync_services.go index 41b8925c76..9a299e655f 100644 --- a/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/sync_services.go +++ b/vendor/github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting/sync_services.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 @@ -19,12 +19,14 @@ import ( "context" "fmt" - "github.com/devfile/devworkspace-operator/pkg/constants" - "github.com/devfile/devworkspace-operator/pkg/provision/sync" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/provision/sync" + controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" ) @@ -38,7 +40,10 @@ func (r *DevWorkspaceRoutingReconciler) syncServices(routing *controllerv1alpha1 toDelete := getServicesToDelete(clusterServices, specServices) for _, service := range toDelete { - err := r.Delete(context.TODO(), &service) + if !metav1.IsControlledBy(&service, routing) { + continue + } + err := r.Delete(context.TODO(), &service, client.Preconditions{UID: &service.UID, ResourceVersion: &service.ResourceVersion}) if err != nil { return false, nil, err } @@ -46,10 +51,11 @@ func (r *DevWorkspaceRoutingReconciler) syncServices(routing *controllerv1alpha1 } clusterAPI := sync.ClusterAPI{ - Client: r.Client, - Scheme: r.Scheme, - Logger: r.Log.WithValues("Request.Namespace", routing.Namespace, "Request.Name", routing.Name), - Ctx: context.TODO(), + Client: r.Client, + NonCachingClient: r.NonCachingClient, + Scheme: r.Scheme, + Logger: r.Log.WithValues("Request.Namespace", routing.Namespace, "Request.Name", routing.Name), + Ctx: context.TODO(), } var updatedClusterServices []corev1.Service diff --git a/vendor/github.com/devfile/devworkspace-operator/pkg/common/naming.go b/vendor/github.com/devfile/devworkspace-operator/pkg/common/naming.go index c7f2504f63..0a0440282f 100644 --- a/vendor/github.com/devfile/devworkspace-operator/pkg/common/naming.go +++ b/vendor/github.com/devfile/devworkspace-operator/pkg/common/naming.go @@ -22,6 +22,7 @@ import ( "strings" dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/constants" ) @@ -32,10 +33,31 @@ func DevWorkspaceRoutingName(workspaceId string) string { } func EndpointName(endpointName string) string { - name := strings.ToLower(endpointName) - name = NonAlphaNumRegexp.ReplaceAllString(name, "-") - name = strings.Trim(name, "-") - return name + // Lowercase ASCII letters, digits, and single internal hyphens need no normalization. + // Return these names unchanged to avoid regexp work and allocations during admission scans. + for i := 0; i < len(endpointName); i++ { + c := endpointName[i] + switch { + case c >= 'a' && c <= 'z' || c >= '0' && c <= '9': + case c == '-' && i > 0 && i < len(endpointName)-1 && endpointName[i-1] != '-': + default: + // Generated by Codex. Collapse non-alphanumeric runs without regexp allocations. + separator := false + name := strings.Map(func(c rune) rune { + if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' { + separator = false + return c + } + if separator { + return -1 + } + separator = true + return '-' + }, strings.ToLower(endpointName)) + return strings.Trim(name, "-") + } + } + return endpointName } func PortName(endpoint dw.Endpoint) string { @@ -101,7 +123,13 @@ func DeploymentName(workspaceId string) string { } func ServingCertVolumeName(serviceName string) string { - return fmt.Sprintf("devworkspace-serving-cert-%s", serviceName) + volumeName := fmt.Sprintf("devworkspace-serving-cert-%s", serviceName) + if len(volumeName) <= 63 { + return volumeName + } + // Generated by Codex. Keep hashes apart from unchanged names derived from short services. + hash := sha256.Sum256([]byte(serviceName)) + return fmt.Sprintf("devworkspace-cert-%x", hash[:10]) } func PVCCleanupJobName(workspaceId string) string { diff --git a/vendor/github.com/devfile/devworkspace-operator/pkg/config/defaults.go b/vendor/github.com/devfile/devworkspace-operator/pkg/config/defaults.go index 7b34e554be..1b772e0ed7 100644 --- a/vendor/github.com/devfile/devworkspace-operator/pkg/config/defaults.go +++ b/vendor/github.com/devfile/devworkspace-operator/pkg/config/defaults.go @@ -219,15 +219,7 @@ func setDefaultOverrideConfig() error { } func setDefaultNetworkPolicy() error { - if !infrastructure.IsInitialized() { - return fmt.Errorf("can not set default network policy, infrastructure not detected") - } - operatorNamespace, err := infrastructure.GetNamespace() - if err != nil { - return err - } - - ingress, egress, err := GetDefaultNetworkPolicy(operatorNamespace) + ingress, egress, err := GetDefaultNetworkPolicy() if err != nil { return err } @@ -243,11 +235,19 @@ func setDefaultNetworkPolicy() error { // GetDefaultNetworkPolicy returns the default NetworkPolicy applied to DevWorkspace pods. // It is exposed publicly for other operators (such as che-operator) that need to read // and extend the default rules rather than hardcoding or duplicating them. -func GetDefaultNetworkPolicy(operatorNamespace string) ( +func GetDefaultNetworkPolicy() ( []networkingv1.NetworkPolicyIngressRule, []networkingv1.NetworkPolicyEgressRule, error, ) { + if !infrastructure.IsInitialized() { + return nil, nil, fmt.Errorf("can not set default network policy, infrastructure not detected") + } + operatorNamespace, err := infrastructure.GetNamespace() + if err != nil { + return nil, nil, err + } + var ingressPolicyRules []networkingv1.NetworkPolicyIngressRule if infrastructure.IsOpenShift() { allowFromDevWorkspaceIngressPolicyRule := networkingv1.NetworkPolicyIngressRule{ diff --git a/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/service.go b/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/service.go new file mode 100644 index 0000000000..c15c6cd321 --- /dev/null +++ b/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/service.go @@ -0,0 +1,31 @@ +// 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 +// +// 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 sync + +import "fmt" + +// ServiceConflictError reports a discoverable Service owned by another workspace. +// Generated by Codex +type ServiceConflictError struct { + EndpointName string + WorkspaceName string +} + +// Generated by Codex +func (e *ServiceConflictError) Error() string { + if e.WorkspaceName == "" { + return fmt.Sprintf("discoverable endpoint '%s' is already in use by another workspace", e.EndpointName) + } + return fmt.Sprintf("discoverable endpoint '%s' is already in use by workspace '%s'", e.EndpointName, e.WorkspaceName) +} diff --git a/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/sync.go b/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/sync.go index 25cfd9a36e..784c7c69f3 100644 --- a/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/sync.go +++ b/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/sync.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 @@ -17,8 +17,6 @@ import ( "fmt" "reflect" - "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" - "github.com/devfile/devworkspace-operator/pkg/config" "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" routev1 "github.com/openshift/api/route/v1" @@ -27,8 +25,13 @@ import ( networkingv1 "k8s.io/api/networking/v1" rbacv1 "k8s.io/api/rbac/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" crclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" + "github.com/devfile/devworkspace-operator/pkg/config" + "github.com/devfile/devworkspace-operator/pkg/constants" ) // IsRecognizedObject returns whether the provided object kind is recognized by the sync package to support updating @@ -44,11 +47,23 @@ func IsRecognizedObject(specObj crclient.Object) bool { // as required. If specObj is in sync with the cluster, returns the object as it exists on the cluster. Returns a // NotInSyncError if an update is required, UnrecoverableSyncError if object provided is invalid, or generic error // if an unexpected error is encountered +// Generated by Codex func SyncObjectWithCluster(specObj crclient.Object, api ClusterAPI) (crclient.Object, error) { objType := reflect.TypeOf(specObj).Elem() clusterObj := reflect.New(objType).Interface().(crclient.Object) - err := api.Client.Get(api.Ctx, types.NamespacedName{Name: specObj.GetName(), Namespace: specObj.GetNamespace()}, clusterObj) + reader := api.Client + service, isService := specObj.(*corev1.Service) + var owner *metav1.OwnerReference + if isService { + owner = metav1.GetControllerOf(service) + } + // Routing Services can be hidden by the cache when their workspace label is missing. + routingService := isService && (service.Annotations[constants.DevWorkspaceDiscoverableServiceAnnotation] == "true" || owner != nil && owner.Kind == "DevWorkspaceRouting") + if routingService && api.NonCachingClient != nil { + reader = api.NonCachingClient + } + err := reader.Get(api.Ctx, types.NamespacedName{Name: specObj.GetName(), Namespace: specObj.GetNamespace()}, clusterObj) if err != nil { if k8sErrors.IsNotFound(err) { return nil, createObjectGeneric(specObj, api) @@ -56,6 +71,15 @@ func SyncObjectWithCluster(specObj crclient.Object, api ClusterAPI) (crclient.Ob return nil, err } + if routingService { + existing := clusterObj.(*corev1.Service) + existingOwner := metav1.GetControllerOf(existing) + if owner != nil && (existingOwner == nil || owner.UID != existingOwner.UID) || + owner == nil && (service.Labels[constants.DevWorkspaceIDLabel] == "" || existing.Labels[constants.DevWorkspaceIDLabel] != service.Labels[constants.DevWorkspaceIDLabel]) { + return nil, &UnrecoverableSyncError{&ServiceConflictError{EndpointName: service.Name, WorkspaceName: existing.Labels[constants.DevWorkspaceNameLabel]}} + } + } + if !isMutableObject(specObj) { // TODO: we could still update labels here, or treat a need to update as a fatal error return clusterObj, nil } @@ -141,6 +165,10 @@ func createObjectGeneric(specObj crclient.Object, api ClusterAPI) error { api.Logger.Info("Created object", "kind", reflect.TypeOf(specObj).Elem().String(), "name", specObj.GetName()) return NewNotInSync(specObj, CreatedObjectReason) case k8sErrors.IsAlreadyExists(err): + // Services need a fresh instance to preserve ClusterIP and validate ownership. + if _, ok := specObj.(*corev1.Service); ok { + return NewNotInSync(specObj, NeedRetryReason) + } // Need to try to update the object to address an edge case where removing a labelselector // results in the object not being tracked by the controller's cache. return updateObjectGeneric(specObj, nil, api) diff --git a/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/update.go b/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/update.go index 3a5b75d7c3..064a42e6df 100644 --- a/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/update.go +++ b/vendor/github.com/devfile/devworkspace-operator/pkg/provision/sync/update.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 @@ -48,6 +48,7 @@ func serviceUpdateFunc(spec, cluster crclient.Object) (crclient.Object, error) { specService := spec.DeepCopyObject().(*corev1.Service) clusterService := cluster.(*corev1.Service) specService.ResourceVersion = clusterService.ResourceVersion + specService.UID = clusterService.UID specService.Spec.ClusterIP = clusterService.Spec.ClusterIP return specService, nil } diff --git a/vendor/modules.txt b/vendor/modules.txt index 0d1591dc37..b75dfecb37 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -28,7 +28,7 @@ github.com/davecgh/go-spew/spew github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2 github.com/devfile/api/v2/pkg/attributes github.com/devfile/api/v2/pkg/devfile -# github.com/devfile/devworkspace-operator v0.2.1-0.20261008221424-9e9bab4780a9 +# github.com/devfile/devworkspace-operator v0.2.1-0.20261008221424-9e9bab4780a9 => github.com/akurinnoy/devworkspace-operator v0.0.0-20261008102644-4d93c0891277 ## explicit; go 1.26.0 github.com/devfile/devworkspace-operator/apis/controller/v1alpha1 github.com/devfile/devworkspace-operator/controllers/controller/devworkspacerouting @@ -1134,3 +1134,4 @@ sigs.k8s.io/structured-merge-diff/v6/value # sigs.k8s.io/yaml v1.6.0 ## explicit; go 1.22 sigs.k8s.io/yaml +# github.com/devfile/devworkspace-operator => github.com/akurinnoy/devworkspace-operator v0.0.0-20261008102644-4d93c0891277