Skip to content

Commit e235316

Browse files
committed
Add call to reconcile VirtualService
Signed-off-by: Matheus Cruz <[email protected]>
1 parent bc4e445 commit e235316

File tree

7 files changed

+16795
-32
lines changed

7 files changed

+16795
-32
lines changed

workspaces/controller/internal/controller/suite_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ var _ = BeforeSuite(func() {
7070

7171
By("bootstrapping test environment")
7272
testEnv = &envtest.Environment{
73-
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
73+
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases"), filepath.Join("..", "..", "test", "crd")},
7474
ErrorIfCRDPathMissing: true,
7575

7676
// The BinaryAssetsDirectory is only required if you want to run the tests directly without call the makefile target test.

workspaces/controller/internal/controller/workspace_controller.go

+18-28
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package controller
1919
import (
2020
"context"
2121
"fmt"
22+
"github.com/kubeflow/notebooks/workspaces/controller/internal/istio"
2223
"reflect"
2324
"strings"
2425

@@ -53,8 +54,6 @@ const (
5354
workspaceSelectorLabel = "statefulset"
5455

5556
// lengths for resource names
56-
generateNameSuffixLength = 6
57-
maxServiceNameLength = 63
5857
maxStatefulSetNameLength = 52 // https://github.com/kubernetes/kubernetes/issues/64023
5958

6059
// state message formats for Workspace status
@@ -346,6 +345,19 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
346345
// TODO: reconcile the Istio VirtualService to expose the Workspace
347346
// and implement the `spec.podTemplate.httpProxy` options
348347
//
348+
virtualService, err := istio.GenerateIstioVirtualService(workspace, workspaceKind, currentImageConfig, serviceName, log)
349+
if err != nil {
350+
log.Error(err, "unable to generate Istio Virtual Service")
351+
}
352+
log.Info(fmt.Sprintf("VirtualService %s", virtualService))
353+
354+
if err := ctrl.SetControllerReference(workspace, virtualService, r.Scheme); err != nil {
355+
return ctrl.Result{}, err
356+
}
357+
358+
if err := istio.ReconcileVirtualService(ctx, r.Client, virtualService.GetName(), virtualService.GetNamespace(), virtualService, log); err != nil {
359+
return ctrl.Result{}, err
360+
}
349361

350362
// fetch Pod
351363
// NOTE: the first StatefulSet Pod is always called "{statefulSetName}-0"
@@ -555,25 +567,10 @@ func getPodConfig(workspace *kubefloworgv1beta1.Workspace, workspaceKind *kubefl
555567
}
556568
}
557569

558-
// generateNamePrefix generates a name prefix for a Workspace
559-
// the format is "ws-{WORKSPACE_NAME}-" the workspace name is truncated to fit within the max length
560-
func generateNamePrefix(workspaceName string, maxLength int) string {
561-
namePrefix := fmt.Sprintf("ws-%s", workspaceName)
562-
maxLength = maxLength - generateNameSuffixLength // subtract 6 for the `metadata.generateName` suffix
563-
maxLength = maxLength - 1 // subtract 1 for the trailing "-"
564-
if len(namePrefix) > maxLength {
565-
namePrefix = namePrefix[:min(len(namePrefix), maxLength)]
566-
}
567-
if namePrefix[len(namePrefix)-1] != '-' {
568-
namePrefix = namePrefix + "-"
569-
}
570-
return namePrefix
571-
}
572-
573570
// generateStatefulSet generates a StatefulSet for a Workspace
574571
func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind *kubefloworgv1beta1.WorkspaceKind, imageConfigSpec kubefloworgv1beta1.ImageConfigSpec, podConfigSpec kubefloworgv1beta1.PodConfigSpec) (*appsv1.StatefulSet, error) {
575572
// generate name prefix
576-
namePrefix := generateNamePrefix(workspace.Name, maxStatefulSetNameLength)
573+
namePrefix := helper.GenerateNamePrefix(workspace.Name, maxStatefulSetNameLength)
577574

578575
// generate replica count
579576
replicas := int32(1)
@@ -594,14 +591,7 @@ func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind
594591
// define go string template functions
595592
// NOTE: these are used in places like the `extraEnv` values
596593
containerPortsIdMap := make(map[string]kubefloworgv1beta1.ImagePort)
597-
httpPathPrefixFunc := func(portId string) string {
598-
port, ok := containerPortsIdMap[portId]
599-
if ok {
600-
return fmt.Sprintf("/workspace/%s/%s/%s/", workspace.Namespace, workspace.Name, port.Id)
601-
} else {
602-
return ""
603-
}
604-
}
594+
httpPathPrefixFunc := helper.GenerateHttpPathPrefixFunc(workspace, containerPortsIdMap)
605595

606596
// generate container ports
607597
containerPorts := make([]corev1.ContainerPort, len(imageConfigSpec.Ports))
@@ -627,7 +617,7 @@ func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind
627617
env := env.DeepCopy() // copy to avoid modifying the original
628618
if env.Value != "" {
629619
rawValue := env.Value
630-
outValue, err := helper.RenderExtraEnvValueTemplate(rawValue, httpPathPrefixFunc)
620+
outValue, err := helper.RenderValueUsingFunc(rawValue, httpPathPrefixFunc)
631621
if err != nil {
632622
return nil, fmt.Errorf("failed to render extraEnv %q: %w", env.Name, err)
633623
}
@@ -806,7 +796,7 @@ func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind
806796
// generateService generates a Service for a Workspace
807797
func generateService(workspace *kubefloworgv1beta1.Workspace, imageConfigSpec kubefloworgv1beta1.ImageConfigSpec) (*corev1.Service, error) {
808798
// generate name prefix
809-
namePrefix := generateNamePrefix(workspace.Name, maxServiceNameLength)
799+
namePrefix := helper.GenerateNamePrefix(workspace.Name, helper.MaxServiceNameLength)
810800

811801
// generate service ports
812802
servicePorts := make([]corev1.ServicePort, len(imageConfigSpec.Ports))

workspaces/controller/internal/helper/helper.go

+68
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package helper
22

33
import (
4+
"fmt"
5+
"os"
46
"reflect"
57

68
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
@@ -10,6 +12,11 @@ import (
1012
corev1 "k8s.io/api/core/v1"
1113
)
1214

15+
const (
16+
GenerateNameSuffixLength = 6
17+
MaxServiceNameLength = 63
18+
)
19+
1320
// CopyStatefulSetFields updates a target StatefulSet with the fields from a desired StatefulSet, returning true if an update is required.
1421
func CopyStatefulSetFields(desired *appsv1.StatefulSet, target *appsv1.StatefulSet) bool {
1522
requireUpdate := false
@@ -166,3 +173,64 @@ func NormalizePodConfigSpec(spec kubefloworgv1beta1.PodConfigSpec) error {
166173

167174
return nil
168175
}
176+
177+
// GenerateNamePrefix generates a name prefix for a Workspace
178+
// the format is "ws-{WORKSPACE_NAME}-" the workspace name is truncated to fit within the max length
179+
func GenerateNamePrefix(workspaceName string, maxLength int) string {
180+
namePrefix := fmt.Sprintf("ws-%s", workspaceName)
181+
maxLength = maxLength - GenerateNameSuffixLength // subtract 6 for the `metadata.generateName` suffix
182+
maxLength = maxLength - 1 // subtract 1 for the trailing "-"
183+
if len(namePrefix) > maxLength {
184+
namePrefix = namePrefix[:min(len(namePrefix), maxLength)]
185+
}
186+
if namePrefix[len(namePrefix)-1] != '-' {
187+
namePrefix = namePrefix + "-"
188+
}
189+
return namePrefix
190+
}
191+
192+
func RemoveTrailingDash(s string) string {
193+
if len(s) > 0 && s[len(s)-1] == '-' {
194+
return s[:len(s)-1]
195+
}
196+
return s
197+
}
198+
199+
func GetEnvOrDefault(name, defaultValue string) string {
200+
if lookupEnv, exists := os.LookupEnv(name); exists {
201+
return lookupEnv
202+
} else {
203+
return defaultValue
204+
}
205+
}
206+
207+
func GenerateHttpPathPrefixFunc(workspace *kubefloworgv1beta1.Workspace, containerPortsIdMap map[string]kubefloworgv1beta1.ImagePort) func(portId string) string {
208+
return func(portId string) string {
209+
port, ok := containerPortsIdMap[portId]
210+
if ok {
211+
return fmt.Sprintf("/workspace/%s/%s/%s/", workspace.Namespace, workspace.Name, port.Id)
212+
} else {
213+
return ""
214+
}
215+
}
216+
}
217+
218+
func GenerateContainerPortsIdMap(imageConfig *kubefloworgv1beta1.ImageConfigValue) (map[string]kubefloworgv1beta1.ImagePort, error) {
219+
containerPortsIdMap := make(map[string]kubefloworgv1beta1.ImagePort)
220+
221+
containerPorts := make([]corev1.ContainerPort, len(imageConfig.Spec.Ports))
222+
seenPorts := make(map[int32]bool)
223+
for i, port := range imageConfig.Spec.Ports {
224+
if seenPorts[port.Port] {
225+
return nil, fmt.Errorf("duplicate port number %d in imageConfig", port.Port)
226+
}
227+
containerPorts[i] = corev1.ContainerPort{
228+
Name: fmt.Sprintf("http-%d", port.Port),
229+
ContainerPort: port.Port,
230+
Protocol: corev1.ProtocolTCP,
231+
}
232+
seenPorts[port.Port] = true
233+
containerPortsIdMap[port.Id] = port
234+
}
235+
return containerPortsIdMap, nil
236+
}

workspaces/controller/internal/helper/template.go

+23-2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"text/template"
77
)
88

9-
// RenderExtraEnvValueTemplate renders a single WorkspaceKind `spec.podTemplate.extraEnv[].value` string template
10-
func RenderExtraEnvValueTemplate(rawValue string, httpPathPrefixFunc func(string) string) (string, error) {
9+
// RenderValueUsingFunc renders a single WorkspaceKind `spec.podTemplate.extraEnv[].value` string template
10+
func RenderValueUsingFunc(rawValue string, httpPathPrefixFunc func(string) string) (string, error) {
1111

1212
// Parse the raw value as a template
1313
tmpl, err := template.New("value").
@@ -28,3 +28,24 @@ func RenderExtraEnvValueTemplate(rawValue string, httpPathPrefixFunc func(string
2828

2929
return buf.String(), nil
3030
}
31+
32+
func TemplateHeaders(requestHeaders map[string]string, httpPathPrefixFunc func(portId string) string) map[string]string {
33+
34+
if len(requestHeaders) == 0 {
35+
return make(map[string]string, 0)
36+
}
37+
38+
headers := make(map[string]string, len(requestHeaders))
39+
for _, header := range requestHeaders {
40+
value := headers[header]
41+
if value != "" {
42+
out, err := RenderValueUsingFunc(header, httpPathPrefixFunc)
43+
if err != nil {
44+
return make(map[string]string, 0)
45+
}
46+
value = out
47+
}
48+
headers[header] = value
49+
}
50+
return headers
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package istio
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"github.com/go-logr/logr"
7+
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
8+
"github.com/kubeflow/notebooks/workspaces/controller/internal/helper"
9+
apierrors "k8s.io/apimachinery/pkg/api/errors"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
"k8s.io/apimachinery/pkg/types"
12+
"sigs.k8s.io/controller-runtime/pkg/client"
13+
)
14+
15+
const (
16+
ApiVersionIstio = "networking.istio.io/v1"
17+
VirtualServiceKind = "VirtualService"
18+
19+
EnvIstioHost = "ISTIO_HOST"
20+
EnvIstioGateway = "ISTIO_GATEWAY"
21+
ClusterDomain = "CLUSTER_DOMAIN"
22+
)
23+
24+
func GenerateIstioVirtualService(workspace *kubefloworgv1beta1.Workspace, workspaceKind *kubefloworgv1beta1.WorkspaceKind, imageConfig *kubefloworgv1beta1.ImageConfigValue, serviceName string, _ logr.Logger) (*unstructured.Unstructured, error) {
25+
26+
virtualService := &unstructured.Unstructured{}
27+
virtualService.SetAPIVersion(ApiVersionIstio)
28+
virtualService.SetKind(VirtualServiceKind)
29+
30+
prefix := helper.GenerateNamePrefix(workspace.Name, helper.MaxServiceNameLength)
31+
virtualService.SetName(helper.RemoveTrailingDash(prefix))
32+
virtualService.SetNamespace(workspace.Namespace)
33+
34+
// .spec.gateways
35+
istioGateway := helper.GetEnvOrDefault(EnvIstioGateway, "kubeflow/kubeflow-gateway")
36+
if err := unstructured.SetNestedStringSlice(virtualService.Object, []string{istioGateway},
37+
"spec", "gateways"); err != nil {
38+
return nil, fmt.Errorf("set .spec.gateways error: %v", err)
39+
}
40+
41+
istioHost := helper.GetEnvOrDefault(EnvIstioHost, "*")
42+
if err := unstructured.SetNestedStringSlice(virtualService.Object, []string{istioHost},
43+
"spec", "gateways"); err != nil {
44+
return nil, fmt.Errorf("set .spec.hosts error: %v", err)
45+
}
46+
47+
var prefixes []string
48+
for _, imagePort := range imageConfig.Spec.Ports {
49+
prefix := fmt.Sprintf("/workspace/%s/%s/%s", workspace.Namespace, workspace.Name, imagePort.Id)
50+
prefixes = append(prefixes, prefix)
51+
}
52+
53+
var httpRoutes []interface{}
54+
55+
host := fmt.Sprintf("%s.%s.svc.%s", serviceName, workspace.Namespace, helper.GetEnvOrDefault(ClusterDomain, "cluster.local"))
56+
57+
// generate container ports
58+
// TODO: It can be better
59+
containerPortsIdMap, err := helper.GenerateContainerPortsIdMap(imageConfig)
60+
if errContainerPorts := unstructured.SetNestedStringSlice(virtualService.Object, []string{istioHost},
61+
"spec", "gateways"); err != nil {
62+
return nil, fmt.Errorf("set .spec.hosts error: %v", errContainerPorts)
63+
}
64+
httpPathPrefixFunc := helper.GenerateHttpPathPrefixFunc(workspace, containerPortsIdMap)
65+
66+
for _, imagePort := range imageConfig.Spec.Ports {
67+
68+
httpRoute := map[string]interface{}{
69+
"match": []map[string]interface{}{
70+
{
71+
"uri": map[string]interface{}{
72+
"prefix": fmt.Sprintf("/workspace/%s/%s/%s", workspace.Namespace, workspace.Name, imagePort.Id),
73+
},
74+
},
75+
},
76+
"route": []map[string]interface{}{
77+
{
78+
"destination": map[string]interface{}{
79+
"host": host,
80+
"port": map[string]interface{}{
81+
"number": imagePort.Port,
82+
},
83+
},
84+
},
85+
},
86+
}
87+
88+
if *workspaceKind.Spec.PodTemplate.HTTPProxy.RemovePathPrefix {
89+
httpRoute["rewrite"] = map[string]interface{}{"uri": "/"}
90+
}
91+
92+
// templating.spec.http[].math.headers
93+
setHeaders := helper.TemplateHeaders(workspaceKind.Spec.PodTemplate.HTTPProxy.RequestHeaders.Set, httpPathPrefixFunc)
94+
addHeaders := helper.TemplateHeaders(workspaceKind.Spec.PodTemplate.HTTPProxy.RequestHeaders.Add, httpPathPrefixFunc)
95+
96+
removeHeaders := make([]string, len(workspaceKind.Spec.PodTemplate.HTTPProxy.RequestHeaders.Remove))
97+
for i, header := range workspaceKind.Spec.PodTemplate.HTTPProxy.RequestHeaders.Remove {
98+
if header != "" {
99+
out, err := helper.RenderValueUsingFunc(header, httpPathPrefixFunc)
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to render header %q: %w", header, err)
102+
}
103+
header = out
104+
}
105+
removeHeaders[i] = header
106+
}
107+
108+
httpRoute["headers"] = map[string]interface{}{
109+
"request": map[string]interface{}{
110+
"add": setHeaders,
111+
"set": addHeaders,
112+
"remove": removeHeaders,
113+
},
114+
}
115+
116+
httpRoutes = append(httpRoutes, httpRoute)
117+
}
118+
119+
virtualService.Object["spec"] = map[string]interface{}{
120+
"gateways": []string{
121+
istioGateway,
122+
},
123+
"hosts": []string{
124+
istioHost,
125+
},
126+
"http": httpRoutes,
127+
}
128+
129+
return virtualService, nil
130+
}
131+
132+
func ReconcileVirtualService(ctx context.Context, r client.Client, virtualServiceName, namespace string, virtualService *unstructured.Unstructured, log logr.Logger) error {
133+
foundVirtualService := &unstructured.Unstructured{}
134+
foundVirtualService.SetAPIVersion(ApiVersionIstio)
135+
foundVirtualService.SetKind(VirtualServiceKind)
136+
justCreated := false
137+
if err := r.Get(ctx, types.NamespacedName{Name: virtualServiceName, Namespace: namespace}, foundVirtualService); err != nil {
138+
if apierrors.IsNotFound(err) {
139+
log.Info("Creating virtual service", "namespace", namespace, "name", virtualServiceName)
140+
if err := r.Create(ctx, virtualService); err != nil {
141+
log.Error(err, "unable to create virtual service")
142+
return err
143+
}
144+
justCreated = true
145+
} else {
146+
log.Error(err, "error getting virtual service")
147+
return err
148+
}
149+
}
150+
if !justCreated {
151+
log.Info("Updating virtual service", "namespace", namespace, "name", virtualServiceName)
152+
if err := r.Update(ctx, foundVirtualService); err != nil {
153+
log.Error(err, "unable to update virtual service")
154+
return err
155+
}
156+
}
157+
158+
return nil
159+
}

workspaces/controller/internal/webhook/workspacekind_webhook.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ func validateExtraEnv(workspaceKind *kubefloworgv1beta1.WorkspaceKind) []*field.
493493
for _, env := range workspaceKind.Spec.PodTemplate.ExtraEnv {
494494
if env.Value != "" {
495495
rawValue := env.Value
496-
_, err := helper.RenderExtraEnvValueTemplate(rawValue, httpPathPrefixFunc)
496+
_, err := helper.RenderValueUsingFunc(rawValue, httpPathPrefixFunc)
497497
if err != nil {
498498
extraEnvPath := field.NewPath("spec", "podTemplate", "extraEnv").Key(env.Name).Child("value")
499499
errs = append(errs, field.Invalid(extraEnvPath, rawValue, err.Error()))

0 commit comments

Comments
 (0)