Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions controllers/devworkspace/solver/che_routing.go
Original file line number Diff line number Diff line change
@@ -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/
Expand Down Expand Up @@ -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{
Expand Down
201 changes: 189 additions & 12 deletions controllers/devworkspace/solver/che_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ package solver

import (
"context"
"errors"
"fmt"
"strings"
"testing"
Expand All @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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{
Expand All @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading