Skip to content

Commit 96c6a02

Browse files
committed
feat : support service annotations in basic solver
Signed-off-by: Rohan Kumar <[email protected]>
1 parent 724d742 commit 96c6a02

File tree

10 files changed

+357
-40
lines changed

10 files changed

+357
-40
lines changed

controllers/controller/devworkspacerouting/devworkspacerouting_controller_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ var _ = Describe("DevWorkspaceRouting Controller", func() {
114114
err := k8sClient.Get(ctx, serviceNamespacedName, createdService)
115115
return err == nil
116116
}, timeout, interval).Should(BeTrue(), "Service should exist in cluster")
117+
Expect(createdService.ObjectMeta.Annotations).Should(HaveKeyWithValue(serviceAnnotationKey, serviceAnnotationValue), "Service should have annotation")
117118
Expect(createdService.Spec.Selector).Should(Equal(createdDWR.Spec.PodSelector), "Service should have pod selector from DevWorkspace metadata")
118119
Expect(createdService.Labels).Should(Equal(ExpectedLabels), "Service should contain DevWorkspace ID label")
119120
expectedOwnerReference := devWorkspaceRoutingOwnerRef(createdDWR)

controllers/controller/devworkspacerouting/solvers/basic_solver.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,38 @@ var nginxIngressAnnotations = func(endpointName string, endpointAnnotations map[
4343
return annotations
4444
}
4545

46+
func serviceAnnotations(sourceAnnotations map[string]string, isDiscoverable bool, serviceRoutingConfig controllerv1alpha1.Service) map[string]string {
47+
annotations := make(map[string]string)
48+
if sourceAnnotations != nil && len(sourceAnnotations) > 0 {
49+
for k, v := range sourceAnnotations {
50+
annotations[k] = v
51+
}
52+
}
53+
if isDiscoverable {
54+
annotations[constants.DevWorkspaceDiscoverableServiceAnnotation] = "true"
55+
}
56+
if serviceRoutingConfig.Annotations != nil && len(serviceRoutingConfig.Annotations) > 0 {
57+
for k, v := range serviceRoutingConfig.Annotations {
58+
annotations[k] = v
59+
}
60+
}
61+
return annotations
62+
}
63+
4664
// Basic solver exposes endpoints without any authentication
4765
// According to the current cluster there is different behavior:
4866
// Kubernetes: use Ingresses without TLS
4967
// OpenShift: use Routes with TLS enabled
5068
type BasicSolver struct{}
5169

70+
var routingSuffixSupplier = func() string {
71+
return config.GetGlobalConfig().Routing.ClusterHostSuffix
72+
}
73+
74+
var isOpenShift = func() bool {
75+
return infrastructure.IsOpenShift()
76+
}
77+
5278
var _ RoutingSolver = (*BasicSolver)(nil)
5379

5480
func (s *BasicSolver) FinalizerRequired(*controllerv1alpha1.DevWorkspaceRouting) bool {
@@ -63,16 +89,16 @@ func (s *BasicSolver) GetSpecObjects(routing *controllerv1alpha1.DevWorkspaceRou
6389
routingObjects := RoutingObjects{}
6490

6591
// TODO: Use workspace-scoped ClusterHostSuffix to allow overriding
66-
routingSuffix := config.GetGlobalConfig().Routing.ClusterHostSuffix
92+
routingSuffix := routingSuffixSupplier()
6793
if routingSuffix == "" {
6894
return routingObjects, &RoutingInvalid{"basic routing requires .config.routing.clusterHostSuffix to be set in operator config"}
6995
}
7096

7197
spec := routing.Spec
72-
services := getServicesForEndpoints(spec.Endpoints, workspaceMeta)
73-
services = append(services, GetDiscoverableServicesForEndpoints(spec.Endpoints, workspaceMeta)...)
98+
services := getServicesForEndpoints(spec, workspaceMeta)
99+
services = append(services, GetDiscoverableServicesForEndpoints(spec, workspaceMeta)...)
74100
routingObjects.Services = services
75-
if infrastructure.IsOpenShift() {
101+
if isOpenShift() {
76102
routingObjects.Routes = getRoutesForSpec(routingSuffix, spec.Endpoints, workspaceMeta)
77103
} else {
78104
routingObjects.Ingresses = getIngressesForSpec(routingSuffix, spec.Endpoints, workspaceMeta)
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) 2019-2025 Red Hat, Inc.
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package solvers
15+
16+
import (
17+
"testing"
18+
19+
"github.com/devfile/devworkspace-operator/apis/controller/v1alpha1"
20+
"github.com/stretchr/testify/assert"
21+
corev1 "k8s.io/api/core/v1"
22+
networkingv1 "k8s.io/api/networking/v1"
23+
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
"k8s.io/apimachinery/pkg/util/intstr"
26+
)
27+
28+
func TestServiceAnnotations(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
sourceAnnotations map[string]string
32+
isDiscoverable bool
33+
serviceRoutingConfig v1alpha1.Service
34+
expectedAnnotations map[string]string
35+
}{
36+
{
37+
name: "No annotations provided and discoverable disabled should return empty",
38+
sourceAnnotations: nil,
39+
isDiscoverable: false,
40+
serviceRoutingConfig: v1alpha1.Service{
41+
Annotations: nil,
42+
},
43+
expectedAnnotations: map[string]string{},
44+
},
45+
{
46+
name: "Source annotations present and discoverable disabled should return source annotations",
47+
sourceAnnotations: map[string]string{
48+
"key1": "value1",
49+
"key2": "value2",
50+
},
51+
isDiscoverable: false,
52+
serviceRoutingConfig: v1alpha1.Service{
53+
Annotations: nil,
54+
},
55+
expectedAnnotations: map[string]string{
56+
"key1": "value1",
57+
"key2": "value2",
58+
},
59+
},
60+
{
61+
name: "Discoverable annotation enabled should return discoverable annotation",
62+
sourceAnnotations: nil,
63+
isDiscoverable: true,
64+
serviceRoutingConfig: v1alpha1.Service{
65+
Annotations: nil,
66+
},
67+
expectedAnnotations: map[string]string{
68+
"controller.devfile.io/discoverable-service": "true",
69+
},
70+
},
71+
{
72+
name: "DevWorkspaceRouting Service routing config annotations merged with source annotations",
73+
sourceAnnotations: map[string]string{
74+
"key1": "value1",
75+
},
76+
isDiscoverable: false,
77+
serviceRoutingConfig: v1alpha1.Service{
78+
Annotations: map[string]string{
79+
"key3": "value3",
80+
},
81+
},
82+
expectedAnnotations: map[string]string{
83+
"key1": "value1",
84+
"key3": "value3",
85+
},
86+
},
87+
{
88+
name: "DevWorkspaceRouting Service routing config annotations merged with source annotations and discoverable annotation",
89+
sourceAnnotations: map[string]string{
90+
"key1": "value1",
91+
},
92+
isDiscoverable: true,
93+
serviceRoutingConfig: v1alpha1.Service{
94+
Annotations: map[string]string{
95+
"key3": "value3",
96+
},
97+
},
98+
expectedAnnotations: map[string]string{
99+
"controller.devfile.io/discoverable-service": "true",
100+
"key1": "value1",
101+
"key3": "value3",
102+
},
103+
},
104+
}
105+
106+
for _, tt := range tests {
107+
t.Run(tt.name, func(t *testing.T) {
108+
// Given + When
109+
result := serviceAnnotations(tt.sourceAnnotations, tt.isDiscoverable, tt.serviceRoutingConfig)
110+
// Then
111+
assert.Equal(t, tt.expectedAnnotations, result)
112+
})
113+
}
114+
}
115+
116+
var devWorkspaceRouting = v1alpha1.DevWorkspaceRouting{
117+
Spec: v1alpha1.DevWorkspaceRoutingSpec{
118+
DevWorkspaceId: "workspaceb978dc9bd4ba428b",
119+
RoutingClass: "basic",
120+
Endpoints: map[string]v1alpha1.EndpointList{
121+
"component1": []v1alpha1.Endpoint{
122+
{
123+
Name: "endpoint1",
124+
TargetPort: 8080,
125+
Exposure: "public",
126+
Protocol: "http",
127+
Secure: false,
128+
Path: "/test",
129+
Attributes: map[string]apiext.JSON{},
130+
Annotations: map[string]string{
131+
"endpoint-annotation-key1": "endpoint-annotation-value1",
132+
},
133+
},
134+
},
135+
},
136+
PodSelector: map[string]string{
137+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
138+
},
139+
Service: map[string]v1alpha1.Service{
140+
"component1": {
141+
Annotations: map[string]string{
142+
"service-annotation-key": "service-annotation-value",
143+
},
144+
},
145+
},
146+
},
147+
}
148+
149+
func TestGetSpecObjects_WhenValidDWRProvidedAndOpenShiftUnavailable_ThenGenerateRoutingObjectsServiceAndIngress(t *testing.T) {
150+
// Given
151+
basicSolver := &BasicSolver{}
152+
routingSuffixSupplier = func() string {
153+
return "test.routing"
154+
}
155+
isOpenShift = func() bool {
156+
return false
157+
}
158+
dwRouting := &devWorkspaceRouting
159+
workspaceMeta := DevWorkspaceMetadata{
160+
DevWorkspaceId: "workspaceb978dc9bd4ba428b",
161+
Namespace: "test",
162+
PodSelector: map[string]string{
163+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
164+
},
165+
}
166+
167+
// When
168+
routingObjects, err := basicSolver.GetSpecObjects(dwRouting, workspaceMeta)
169+
170+
// Then
171+
assert.NotNil(t, routingObjects)
172+
assert.NoError(t, err)
173+
assert.Len(t, routingObjects.Services, 1)
174+
assert.Equal(t, corev1.Service{
175+
ObjectMeta: metav1.ObjectMeta{
176+
Name: "workspaceb978dc9bd4ba428b-service",
177+
Namespace: "test",
178+
Labels: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
179+
Annotations: map[string]string{"service-annotation-key": "service-annotation-value"},
180+
},
181+
Spec: corev1.ServiceSpec{
182+
Type: corev1.ServiceTypeClusterIP,
183+
Ports: []corev1.ServicePort{
184+
{
185+
Name: "endpoint1",
186+
Protocol: corev1.ProtocolTCP,
187+
Port: 8080,
188+
TargetPort: intstr.IntOrString{IntVal: 8080},
189+
},
190+
},
191+
Selector: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
192+
},
193+
}, routingObjects.Services[0])
194+
assert.Len(t, routingObjects.Ingresses, 1)
195+
assert.Equal(t, metav1.ObjectMeta{
196+
Name: "workspaceb978dc9bd4ba428b-endpoint1",
197+
Namespace: "test",
198+
Labels: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
199+
Annotations: map[string]string{
200+
"controller.devfile.io/endpoint_name": "endpoint1",
201+
"endpoint-annotation-key1": "endpoint-annotation-value1",
202+
"nginx.ingress.kubernetes.io/rewrite-target": "/",
203+
"nginx.ingress.kubernetes.io/ssl-redirect": "false",
204+
},
205+
}, routingObjects.Ingresses[0].ObjectMeta)
206+
assert.Len(t, routingObjects.Ingresses[0].Spec.Rules, 1)
207+
assert.Equal(t, "workspaceb978dc9bd4ba428b-endpoint1-8080.test.routing", routingObjects.Ingresses[0].Spec.Rules[0].Host)
208+
assert.Len(t, routingObjects.Ingresses[0].Spec.Rules[0].HTTP.Paths, 1)
209+
assert.Equal(t, networkingv1.IngressBackend{
210+
Service: &networkingv1.IngressServiceBackend{
211+
Name: "workspaceb978dc9bd4ba428b-service",
212+
Port: networkingv1.ServiceBackendPort{Number: int32(8080)},
213+
},
214+
}, routingObjects.Ingresses[0].Spec.Rules[0].HTTP.Paths[0].Backend)
215+
assert.Len(t, routingObjects.Routes, 0)
216+
}
217+
218+
func TestGetSpecObjects_WhenValidDWRProvidedAndOpenShiftAvailable_ThenGenerateRoutingObjectsServiceAndRoute(t *testing.T) {
219+
// Given
220+
basicSolver := &BasicSolver{}
221+
routingSuffixSupplier = func() string {
222+
return "test.routing"
223+
}
224+
isOpenShift = func() bool {
225+
return true
226+
}
227+
dwRouting := &devWorkspaceRouting
228+
workspaceMeta := DevWorkspaceMetadata{
229+
DevWorkspaceId: "workspaceb978dc9bd4ba428b",
230+
Namespace: "test",
231+
PodSelector: map[string]string{
232+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
233+
},
234+
}
235+
236+
// When
237+
routingObjects, err := basicSolver.GetSpecObjects(dwRouting, workspaceMeta)
238+
239+
// Then
240+
assert.NotNil(t, routingObjects)
241+
assert.NoError(t, err)
242+
assert.Len(t, routingObjects.Services, 1)
243+
assert.Equal(t, corev1.Service{
244+
ObjectMeta: metav1.ObjectMeta{
245+
Name: "workspaceb978dc9bd4ba428b-service",
246+
Namespace: "test",
247+
Labels: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
248+
Annotations: map[string]string{"service-annotation-key": "service-annotation-value"},
249+
},
250+
Spec: corev1.ServiceSpec{
251+
Type: corev1.ServiceTypeClusterIP,
252+
Ports: []corev1.ServicePort{
253+
{
254+
Name: "endpoint1",
255+
Protocol: corev1.ProtocolTCP,
256+
Port: 8080,
257+
TargetPort: intstr.IntOrString{IntVal: 8080},
258+
},
259+
},
260+
Selector: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
261+
},
262+
}, routingObjects.Services[0])
263+
assert.Len(t, routingObjects.Ingresses, 0)
264+
assert.Len(t, routingObjects.Routes, 1)
265+
assert.Equal(t, metav1.ObjectMeta{
266+
Name: "workspaceb978dc9bd4ba428b-endpoint1",
267+
Namespace: "test",
268+
Labels: map[string]string{
269+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
270+
},
271+
Annotations: map[string]string{
272+
"controller.devfile.io/endpoint_name": "endpoint1",
273+
"endpoint-annotation-key1": "endpoint-annotation-value1",
274+
"haproxy.router.openshift.io/rewrite-target": "/",
275+
},
276+
}, routingObjects.Routes[0].ObjectMeta)
277+
assert.Equal(t, "workspaceb978dc9bd4ba428b.test.routing", routingObjects.Routes[0].Spec.Host)
278+
assert.Equal(t, "/endpoint1/", routingObjects.Routes[0].Spec.Path)
279+
assert.Equal(t, "Service", routingObjects.Routes[0].Spec.To.Kind)
280+
assert.Equal(t, "workspaceb978dc9bd4ba428b-service", routingObjects.Routes[0].Spec.To.Name)
281+
}

controllers/controller/devworkspacerouting/solvers/cluster_solver.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func (s *ClusterSolver) Finalize(*controllerv1alpha1.DevWorkspaceRouting) error
4646

4747
func (s *ClusterSolver) GetSpecObjects(routing *controllerv1alpha1.DevWorkspaceRouting, workspaceMeta DevWorkspaceMetadata) (RoutingObjects, error) {
4848
spec := routing.Spec
49-
services := getServicesForEndpoints(spec.Endpoints, workspaceMeta)
49+
services := getServicesForEndpoints(spec, workspaceMeta)
5050
podAdditions := &controllerv1alpha1.PodAdditions{}
5151
if s.TLS {
5252
readOnlyMode := int32(420)

0 commit comments

Comments
 (0)