Skip to content

Commit 2c63671

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

File tree

10 files changed

+340
-40
lines changed

10 files changed

+340
-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: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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+
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
// Given + When
92+
result := serviceAnnotations(tt.sourceAnnotations, tt.isDiscoverable, tt.serviceRoutingConfig)
93+
// Then
94+
assert.Equal(t, tt.expectedAnnotations, result)
95+
})
96+
}
97+
}
98+
99+
var devWorkspaceRouting = v1alpha1.DevWorkspaceRouting{
100+
Spec: v1alpha1.DevWorkspaceRoutingSpec{
101+
DevWorkspaceId: "workspaceb978dc9bd4ba428b",
102+
RoutingClass: "basic",
103+
Endpoints: map[string]v1alpha1.EndpointList{
104+
"component1": []v1alpha1.Endpoint{
105+
{
106+
Name: "endpoint1",
107+
TargetPort: 8080,
108+
Exposure: "public",
109+
Protocol: "http",
110+
Secure: false,
111+
Path: "/test",
112+
Attributes: map[string]apiext.JSON{},
113+
Annotations: map[string]string{
114+
"endpoint-annotation-key1": "endpoint-annotation-value1",
115+
},
116+
},
117+
},
118+
},
119+
PodSelector: map[string]string{
120+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
121+
},
122+
Service: map[string]v1alpha1.Service{
123+
"component1": {
124+
Annotations: map[string]string{
125+
"service-annotation-key": "service-annotation-value",
126+
},
127+
},
128+
},
129+
},
130+
}
131+
132+
func TestGetSpecObjects_WhenValidDWRProvidedAndOpenShiftUnavailable_ThenGenerateRoutingObjectsServiceAndIngress(t *testing.T) {
133+
// Given
134+
basicSolver := &BasicSolver{}
135+
routingSuffixSupplier = func() string {
136+
return "test.routing"
137+
}
138+
isOpenShift = func() bool {
139+
return false
140+
}
141+
dwRouting := &devWorkspaceRouting
142+
workspaceMeta := DevWorkspaceMetadata{
143+
DevWorkspaceId: "workspaceb978dc9bd4ba428b",
144+
Namespace: "test",
145+
PodSelector: map[string]string{
146+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
147+
},
148+
}
149+
150+
// When
151+
routingObjects, err := basicSolver.GetSpecObjects(dwRouting, workspaceMeta)
152+
153+
// Then
154+
assert.NotNil(t, routingObjects)
155+
assert.NoError(t, err)
156+
assert.Len(t, routingObjects.Services, 1)
157+
assert.Equal(t, corev1.Service{
158+
ObjectMeta: metav1.ObjectMeta{
159+
Name: "workspaceb978dc9bd4ba428b-service",
160+
Namespace: "test",
161+
Labels: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
162+
Annotations: map[string]string{"service-annotation-key": "service-annotation-value"},
163+
},
164+
Spec: corev1.ServiceSpec{
165+
Type: corev1.ServiceTypeClusterIP,
166+
Ports: []corev1.ServicePort{
167+
{
168+
Name: "endpoint1",
169+
Protocol: corev1.ProtocolTCP,
170+
Port: 8080,
171+
TargetPort: intstr.IntOrString{IntVal: 8080},
172+
},
173+
},
174+
Selector: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
175+
},
176+
}, routingObjects.Services[0])
177+
assert.Len(t, routingObjects.Ingresses, 1)
178+
assert.Equal(t, metav1.ObjectMeta{
179+
Name: "workspaceb978dc9bd4ba428b-endpoint1",
180+
Namespace: "test",
181+
Labels: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
182+
Annotations: map[string]string{
183+
"controller.devfile.io/endpoint_name": "endpoint1",
184+
"endpoint-annotation-key1": "endpoint-annotation-value1",
185+
"nginx.ingress.kubernetes.io/rewrite-target": "/",
186+
"nginx.ingress.kubernetes.io/ssl-redirect": "false",
187+
},
188+
}, routingObjects.Ingresses[0].ObjectMeta)
189+
assert.Len(t, routingObjects.Ingresses[0].Spec.Rules, 1)
190+
assert.Equal(t, "workspaceb978dc9bd4ba428b-endpoint1-8080.test.routing", routingObjects.Ingresses[0].Spec.Rules[0].Host)
191+
assert.Len(t, routingObjects.Ingresses[0].Spec.Rules[0].HTTP.Paths, 1)
192+
assert.Equal(t, networkingv1.IngressBackend{
193+
Service: &networkingv1.IngressServiceBackend{
194+
Name: "workspaceb978dc9bd4ba428b-service",
195+
Port: networkingv1.ServiceBackendPort{Number: int32(8080)},
196+
},
197+
}, routingObjects.Ingresses[0].Spec.Rules[0].HTTP.Paths[0].Backend)
198+
assert.Len(t, routingObjects.Routes, 0)
199+
}
200+
201+
func TestGetSpecObjects_WhenValidDWRProvidedAndOpenShiftAvailable_ThenGenerateRoutingObjectsServiceAndRoute(t *testing.T) {
202+
// Given
203+
basicSolver := &BasicSolver{}
204+
routingSuffixSupplier = func() string {
205+
return "test.routing"
206+
}
207+
isOpenShift = func() bool {
208+
return true
209+
}
210+
dwRouting := &devWorkspaceRouting
211+
workspaceMeta := DevWorkspaceMetadata{
212+
DevWorkspaceId: "workspaceb978dc9bd4ba428b",
213+
Namespace: "test",
214+
PodSelector: map[string]string{
215+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
216+
},
217+
}
218+
219+
// When
220+
routingObjects, err := basicSolver.GetSpecObjects(dwRouting, workspaceMeta)
221+
222+
// Then
223+
assert.NotNil(t, routingObjects)
224+
assert.NoError(t, err)
225+
assert.Len(t, routingObjects.Services, 1)
226+
assert.Equal(t, corev1.Service{
227+
ObjectMeta: metav1.ObjectMeta{
228+
Name: "workspaceb978dc9bd4ba428b-service",
229+
Namespace: "test",
230+
Labels: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
231+
Annotations: map[string]string{"service-annotation-key": "service-annotation-value"},
232+
},
233+
Spec: corev1.ServiceSpec{
234+
Type: corev1.ServiceTypeClusterIP,
235+
Ports: []corev1.ServicePort{
236+
{
237+
Name: "endpoint1",
238+
Protocol: corev1.ProtocolTCP,
239+
Port: 8080,
240+
TargetPort: intstr.IntOrString{IntVal: 8080},
241+
},
242+
},
243+
Selector: map[string]string{"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b"},
244+
},
245+
}, routingObjects.Services[0])
246+
assert.Len(t, routingObjects.Ingresses, 0)
247+
assert.Len(t, routingObjects.Routes, 1)
248+
assert.Equal(t, metav1.ObjectMeta{
249+
Name: "workspaceb978dc9bd4ba428b-endpoint1",
250+
Namespace: "test",
251+
Labels: map[string]string{
252+
"controller.devfile.io/devworkspace_id": "workspaceb978dc9bd4ba428b",
253+
},
254+
Annotations: map[string]string{
255+
"controller.devfile.io/endpoint_name": "endpoint1",
256+
"endpoint-annotation-key1": "endpoint-annotation-value1",
257+
"haproxy.router.openshift.io/rewrite-target": "/",
258+
},
259+
}, routingObjects.Routes[0].ObjectMeta)
260+
assert.Equal(t, "workspaceb978dc9bd4ba428b.test.routing", routingObjects.Routes[0].Spec.Host)
261+
assert.Equal(t, "/endpoint1/", routingObjects.Routes[0].Spec.Path)
262+
assert.Equal(t, "Service", routingObjects.Routes[0].Spec.To.Kind)
263+
assert.Equal(t, "workspaceb978dc9bd4ba428b-service", routingObjects.Routes[0].Spec.To.Name)
264+
}

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)