Skip to content

Commit 6c0bb84

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

File tree

11 files changed

+16907
-41
lines changed

11 files changed

+16907
-41
lines changed

workspaces/controller/config/manager/kustomization.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ resources:
55
images:
66
- name: controller
77
newName: ghcr.io/kubeflow/notebooks/workspace-controller
8-
newTag: latest
8+
newTag: latest

workspaces/controller/config/samples/jupyterlab_v1beta1_workspacekind.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ spec:
156156
requestHeaders: {}
157157
#set: { "X-RStudio-Root-Path": "{{ .PathPrefix }}" } # for RStudio
158158
#add: {}
159-
#remove: []
159+
#remove: []
160160

161161
## environment variables for Workspace Pods (MUTABLE)
162162
## - spec for EnvVar:

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

+19-34
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
@@ -342,10 +341,20 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
342341
}
343342
}
344343

345-
//
346-
// TODO: reconcile the Istio VirtualService to expose the Workspace
347-
// and implement the `spec.podTemplate.httpProxy` options
348-
//
344+
// VirtualService reconciliation
345+
virtualService, err := istio.GenerateIstioVirtualService(workspace, workspaceKind, currentImageConfig, serviceName, log)
346+
if err != nil {
347+
log.Error(err, "unable to generate Istio Virtual Service")
348+
}
349+
log.Info(fmt.Sprintf("VirtualService %s", virtualService))
350+
351+
if err := ctrl.SetControllerReference(workspace, virtualService, r.Scheme); err != nil {
352+
return ctrl.Result{}, err
353+
}
354+
355+
if err := istio.ReconcileVirtualService(ctx, r.Client, virtualService.GetName(), virtualService.GetNamespace(), virtualService, log); err != nil {
356+
return ctrl.Result{}, err
357+
}
349358

350359
// fetch Pod
351360
// NOTE: the first StatefulSet Pod is always called "{statefulSetName}-0"
@@ -555,25 +564,10 @@ func getPodConfig(workspace *kubefloworgv1beta1.Workspace, workspaceKind *kubefl
555564
}
556565
}
557566

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-
573567
// generateStatefulSet generates a StatefulSet for a Workspace
574568
func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind *kubefloworgv1beta1.WorkspaceKind, imageConfigSpec kubefloworgv1beta1.ImageConfigSpec, podConfigSpec kubefloworgv1beta1.PodConfigSpec) (*appsv1.StatefulSet, error) {
575569
// generate name prefix
576-
namePrefix := generateNamePrefix(workspace.Name, maxStatefulSetNameLength)
570+
namePrefix := helper.GenerateNamePrefix(workspace.Name, maxStatefulSetNameLength)
577571

578572
// generate replica count
579573
replicas := int32(1)
@@ -591,17 +585,7 @@ func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind
591585
imagePullPolicy = *imageConfigSpec.ImagePullPolicy
592586
}
593587

594-
// define go string template functions
595-
// NOTE: these are used in places like the `extraEnv` values
596588
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-
}
605589

606590
// generate container ports
607591
containerPorts := make([]corev1.ContainerPort, len(imageConfigSpec.Ports))
@@ -620,14 +604,15 @@ func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind
620604
// NOTE: we construct this map for use in the go string templates
621605
containerPortsIdMap[port.Id] = port
622606
}
607+
httpPathPrefixFunc := helper.GenerateHttpPathPrefixFunc(workspace, containerPortsIdMap)
623608

624609
// generate container env
625610
containerEnv := make([]corev1.EnvVar, len(workspaceKind.Spec.PodTemplate.ExtraEnv))
626611
for i, env := range workspaceKind.Spec.PodTemplate.ExtraEnv {
627612
env := env.DeepCopy() // copy to avoid modifying the original
628613
if env.Value != "" {
629614
rawValue := env.Value
630-
outValue, err := helper.RenderExtraEnvValueTemplate(rawValue, httpPathPrefixFunc)
615+
outValue, err := helper.RenderWithHttpPathPrefixFunc(rawValue, httpPathPrefixFunc)
631616
if err != nil {
632617
return nil, fmt.Errorf("failed to render extraEnv %q: %w", env.Name, err)
633618
}
@@ -806,7 +791,7 @@ func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind
806791
// generateService generates a Service for a Workspace
807792
func generateService(workspace *kubefloworgv1beta1.Workspace, imageConfigSpec kubefloworgv1beta1.ImageConfigSpec) (*corev1.Service, error) {
808793
// generate name prefix
809-
namePrefix := generateNamePrefix(workspace.Name, maxServiceNameLength)
794+
namePrefix := helper.GenerateNamePrefix(workspace.Name, helper.MaxServiceNameLength)
810795

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

workspaces/controller/internal/controller/workspace_controller_test.go

+17
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ package controller
1818

1919
import (
2020
"fmt"
21+
"github.com/kubeflow/notebooks/workspaces/controller/internal/istio"
22+
"github.com/onsi/gomega/format"
23+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2124
"time"
2225

2326
"k8s.io/utils/ptr"
@@ -36,6 +39,9 @@ import (
3639

3740
var _ = Describe("Workspace Controller", func() {
3841

42+
// https://onsi.github.io/gomega/#adjusting-output
43+
format.MaxLength = 10000
44+
3945
// Define utility constants for object names and testing timeouts/durations and intervals.
4046
const (
4147
namespaceName = "default"
@@ -189,6 +195,17 @@ var _ = Describe("Workspace Controller", func() {
189195

190196
// TODO: use this to get the Service
191197
//service := serviceList.Items[0]
198+
By("creating a VirtualService")
199+
virtualServiceList := &unstructured.UnstructuredList{}
200+
virtualServiceList.SetAPIVersion(istio.ApiVersionIstio)
201+
virtualServiceList.SetKind(istio.VirtualServiceKind)
202+
Eventually(func() ([]unstructured.Unstructured, error) {
203+
err := k8sClient.List(ctx, virtualServiceList, client.InNamespace(namespaceName))
204+
if err != nil {
205+
return nil, err
206+
}
207+
return virtualServiceList.Items, nil
208+
}, timeout, interval).Should(HaveLen(1))
192209

193210
//
194211
// TODO: populate these tests

workspaces/controller/internal/helper/helper.go

+60
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,56 @@ 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+
// RemoveTrailingDash removes trailing dash from string.
193+
func RemoveTrailingDash(s string) string {
194+
if len(s) > 0 && s[len(s)-1] == '-' {
195+
return s[:len(s)-1]
196+
}
197+
return s
198+
}
199+
200+
// GetEnvOrDefault is a utility function for getting environment variable value, otherwise uses the defaultValue.
201+
func GetEnvOrDefault(name, defaultValue string) string {
202+
if lookupEnv, exists := os.LookupEnv(name); exists {
203+
return lookupEnv
204+
} else {
205+
return defaultValue
206+
}
207+
}
208+
209+
// GenerateContainerPortsIdMap generates a map[string]kubefloworgv1beta1.ImagePort having as key the kubefloworgv1beta1.ImagePort.Id.
210+
func GenerateContainerPortsIdMap(imageConfig *kubefloworgv1beta1.ImageConfigValue) (map[string]kubefloworgv1beta1.ImagePort, error) {
211+
containerPortsIdMap := make(map[string]kubefloworgv1beta1.ImagePort)
212+
213+
containerPorts := make([]corev1.ContainerPort, len(imageConfig.Spec.Ports))
214+
seenPorts := make(map[int32]bool)
215+
for i, port := range imageConfig.Spec.Ports {
216+
if seenPorts[port.Port] {
217+
return nil, fmt.Errorf("duplicate port number %d in imageConfig", port.Port)
218+
}
219+
containerPorts[i] = corev1.ContainerPort{
220+
Name: fmt.Sprintf("http-%d", port.Port),
221+
ContainerPort: port.Port,
222+
Protocol: corev1.ProtocolTCP,
223+
}
224+
seenPorts[port.Port] = true
225+
containerPortsIdMap[port.Id] = port
226+
}
227+
return containerPortsIdMap, nil
228+
}

workspaces/controller/internal/helper/template.go

+37-3
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ package helper
33
import (
44
"bytes"
55
"fmt"
6+
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
67
"text/template"
78
)
89

9-
// RenderExtraEnvValueTemplate renders a single WorkspaceKind `spec.podTemplate.extraEnv[].value` string template
10-
func RenderExtraEnvValueTemplate(rawValue string, httpPathPrefixFunc func(string) string) (string, error) {
10+
// RenderWithHttpPathPrefixFunc renders a string template using templateFunc (Go template function).
11+
func RenderWithHttpPathPrefixFunc(rawValue string, templateFunc func(portId string) string) (string, error) {
1112

1213
// Parse the raw value as a template
1314
tmpl, err := template.New("value").
14-
Funcs(template.FuncMap{"httpPathPrefix": httpPathPrefixFunc}).
15+
Funcs(template.FuncMap{"httpPathPrefix": templateFunc}).
1516
Parse(rawValue)
1617
if err != nil {
1718
err = fmt.Errorf("failed to parse template %q: %w", rawValue, err)
@@ -28,3 +29,36 @@ func RenderExtraEnvValueTemplate(rawValue string, httpPathPrefixFunc func(string
2829

2930
return buf.String(), nil
3031
}
32+
33+
// RenderHeadersWithHttpPathPrefix renders a map[string]string values using httpPathPrefixFunc Go template function.
34+
func RenderHeadersWithHttpPathPrefix(requestHeaders map[string]string, templateFunc func(v string) string) map[string]string {
35+
36+
if len(requestHeaders) == 0 {
37+
return make(map[string]string, 0)
38+
}
39+
40+
headers := make(map[string]string, len(requestHeaders))
41+
for key, value := range requestHeaders {
42+
if value != "" {
43+
out, err := RenderWithHttpPathPrefixFunc(value, templateFunc)
44+
if err != nil {
45+
return make(map[string]string)
46+
}
47+
value = out
48+
}
49+
headers[key] = value
50+
}
51+
return headers
52+
}
53+
54+
// GenerateHttpPathPrefixFunc generates the httpPathPrefix Go template function.
55+
func GenerateHttpPathPrefixFunc(workspace *kubefloworgv1beta1.Workspace, containerPortsIdMap map[string]kubefloworgv1beta1.ImagePort) func(portId string) string {
56+
return func(portId string) string {
57+
port, ok := containerPortsIdMap[portId]
58+
if ok {
59+
return fmt.Sprintf("/workspace/%s/%s/%s/", workspace.Namespace, workspace.Name, port.Id)
60+
} else {
61+
return ""
62+
}
63+
}
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package helper
2+
3+
import (
4+
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
5+
"github.com/onsi/ginkgo/v2"
6+
"github.com/onsi/gomega"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
)
9+
10+
var _ = ginkgo.Describe("helper", func() {
11+
12+
ginkgo.It("should render request headers correctly", func() {
13+
14+
containerPortsIdMap := make(map[string]kubefloworgv1beta1.ImagePort)
15+
containerPortsIdMap["rstudio"] = kubefloworgv1beta1.ImagePort{
16+
Port: 8080,
17+
Id: "rstudio",
18+
}
19+
20+
headers := map[string]string{"X-RStudio-Root-Path": `{{ httpPathPrefix "rstudio" }}`}
21+
22+
ws := &kubefloworgv1beta1.Workspace{
23+
ObjectMeta: metav1.ObjectMeta{
24+
Name: "simple",
25+
Namespace: "default",
26+
},
27+
}
28+
29+
function := GenerateHttpPathPrefixFunc(ws, containerPortsIdMap)
30+
31+
out := RenderHeadersWithHttpPathPrefix(headers, function)
32+
33+
gomega.Expect(out["X-RStudio-Root-Path"]).To(gomega.Equal("/workspace/default/simple/rstudio/"))
34+
})
35+
})

0 commit comments

Comments
 (0)