Skip to content

Commit 50b6026

Browse files
committed
Setup virtualservice to route traffic to relevant workspace
- Update the helper func with CopyDeepVirtualService Signed-off-by: Harshad Reddy Nalla <[email protected]>
1 parent d71a3f5 commit 50b6026

File tree

9 files changed

+242
-3
lines changed

9 files changed

+242
-3
lines changed

workspaces/controller/cmd/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
// to ensure that exec-entrypoint and run can make use of them.
2626
_ "k8s.io/client-go/plugin/pkg/client/auth"
2727

28+
istiov1 "istio.io/client-go/pkg/apis/networking/v1"
2829
"k8s.io/apimachinery/pkg/runtime"
2930
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
3031
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -50,6 +51,8 @@ var (
5051
func init() {
5152
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
5253

54+
utilruntime.Must(istiov1.AddToScheme(scheme))
55+
5356
utilruntime.Must(kubefloworgv1beta1.AddToScheme(scheme))
5457
// +kubebuilder:scaffold:scheme
5558
}

workspaces/controller/config/manager/kustomization.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ apiVersion: kustomize.config.k8s.io/v1beta1
22
kind: Kustomization
33
resources:
44
- manager.yaml
5+
configMapGenerator:
6+
- envs:
7+
- params.env
8+
name: config
9+
generatorOptions:
10+
disableNameSuffixHash: true
511
images:
612
- name: controller
713
newName: ghcr.io/kubeflow/notebooks/workspace-controller

workspaces/controller/config/manager/manager.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ spec:
6464
- --leader-elect
6565
- --health-probe-bind-address=:8081
6666
- --metrics-bind-address=0
67+
env:
68+
- name: USE_ISTIO
69+
valueFrom:
70+
configMapKeyRef:
71+
name: config
72+
key: USE_ISTIO
73+
- name: ISTIO_GATEWAY
74+
valueFrom:
75+
configMapKeyRef:
76+
name: config
77+
key: ISTIO_GATEWAY
78+
- name: ISTIO_HOST
79+
valueFrom:
80+
configMapKeyRef:
81+
name: config
82+
key: ISTIO_HOST
6783
image: controller:latest
6884
imagePullPolicy: IfNotPresent
6985
name: manager
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
USE_ISTIO=true
2+
ISTIO_GATEWAY=kubeflow/kubeflow-gateway
3+
ISTIO_HOST=*
4+
CLUSTER_DOMAIN=cluster.local

workspaces/controller/go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ require (
66
github.com/go-logr/logr v1.4.2
77
github.com/onsi/ginkgo/v2 v2.19.0
88
github.com/onsi/gomega v1.33.1
9+
golang.org/x/time v0.3.0
10+
istio.io/api v1.22.8
11+
istio.io/client-go v1.22.8
912
k8s.io/api v0.31.0
1013
k8s.io/apimachinery v0.31.0
1114
k8s.io/client-go v0.31.0
@@ -56,9 +59,9 @@ require (
5659
golang.org/x/sys v0.21.0 // indirect
5760
golang.org/x/term v0.21.0 // indirect
5861
golang.org/x/text v0.16.0 // indirect
59-
golang.org/x/time v0.3.0 // indirect
6062
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
6163
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
64+
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
6265
google.golang.org/protobuf v1.34.2 // indirect
6366
gopkg.in/inf.v0 v0.9.1 // indirect
6467
gopkg.in/yaml.v2 v2.4.0 // indirect

workspaces/controller/go.sum

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
99
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1010
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
1111
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
12-
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
13-
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
12+
github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
13+
github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
1414
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
1515
github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
1616
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -155,6 +155,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
155155
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
156156
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
157157
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
158+
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
159+
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
158160
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
159161
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
160162
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -170,6 +172,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
170172
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
171173
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
172174
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
175+
istio.io/api v1.22.8 h1:mhkaeFJ13WZ2d6pvL9+exNeQ9UB6HX7e6m+XwO9XoYY=
176+
istio.io/api v1.22.8/go.mod h1:S3l8LWqNYS9yT+d4bH+jqzH2lMencPkW7SKM1Cu9EyM=
177+
istio.io/client-go v1.22.8 h1:wojmt220jSbfhpRDsPiflj2nSFTBuYtZNiW9hqKeaWE=
178+
istio.io/client-go v1.22.8/go.mod h1:noO8SoyMxLwni3w+yGK67aydi2klExjmiqnXyeRS/00=
173179
k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo=
174180
k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE=
175181
k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk=

workspaces/controller/internal/controller/workspace_controller.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ package controller
1919
import (
2020
"context"
2121
"fmt"
22+
"os"
2223
"strings"
2324

2425
"github.com/go-logr/logr"
26+
networkingv1 "istio.io/api/networking/v1"
27+
istiov1 "istio.io/client-go/pkg/apis/networking/v1"
2528
appsv1 "k8s.io/api/apps/v1"
2629
corev1 "k8s.io/api/core/v1"
2730
"k8s.io/apimachinery/pkg/api/equality"
@@ -68,6 +71,7 @@ const (
6871
stateMsgErrorGenFailureService = "Workspace failed to generate Service with error: %s"
6972
stateMsgErrorMultipleStatefulSets = "Workspace owns multiple StatefulSets: %s"
7073
stateMsgErrorMultipleServices = "Workspace owns multiple Services: %s"
74+
stateMsgErrorMultipleVirtualServices = "Workspace owns multiple VirtualServices: %s"
7175
stateMsgErrorStatefulSetWarningEvent = "Workspace StatefulSet has warning event: %s"
7276
stateMsgErrorPodUnschedulable = "Workspace Pod is unschedulable: %s"
7377
stateMsgErrorPodSchedulingGate = "Workspace Pod is waiting for scheduling gate: %s"
@@ -359,6 +363,71 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
359363
// and implement the `spec.podTemplate.httpProxy` options
360364
//
361365

366+
log.V(2).Info("reconciling VirtualService for Workspace")
367+
if os.Getenv("USE_ISTIO") == "true" {
368+
// generateVirtualService
369+
virtualsvc, err := generateVirtualService(workspace, serviceName, currentImageConfig.Spec)
370+
if err != nil {
371+
log.V(0).Info("failed to generate VirtualService for Workspace", "error", err.Error())
372+
return r.updateWorkspaceState(ctx, log, workspace,
373+
kubefloworgv1beta1.WorkspaceStateError,
374+
fmt.Sprintf("failed to generate VirtualService for Workspace: %s", err.Error()),
375+
)
376+
}
377+
if err := ctrl.SetControllerReference(workspace, virtualsvc, r.Scheme); err != nil {
378+
log.Error(err, "unable to set controller reference on VirtualService")
379+
return ctrl.Result{}, err
380+
}
381+
382+
// fetch VirtualServices
383+
// NOTE: we filter by VirtualServices that are owned by the Workspace, not by name
384+
// this allows us to generate a random name for the VirtualService with `metadata.generateName`
385+
var VirtualServiceName string
386+
ownedVirtualServices := &istiov1.VirtualServiceList{}
387+
listOpts = &client.ListOptions{
388+
FieldSelector: fields.OneTermEqualSelector(helper.IndexWorkspaceOwnerField, workspace.Name),
389+
Namespace: req.Namespace,
390+
}
391+
if err := r.List(ctx, ownedVirtualServices, listOpts); err != nil {
392+
log.Error(err, "unable to list VirtualServices")
393+
return ctrl.Result{}, err
394+
}
395+
switch numVirtualServices := len(ownedVirtualServices.Items); {
396+
case numVirtualServices > 1:
397+
virtualServiceList := make([]string, len(ownedVirtualServices.Items))
398+
for i, vs := range ownedVirtualServices.Items {
399+
virtualServiceList[i] = vs.Name
400+
}
401+
virtualServiceListString := strings.Join(virtualServiceList, ", ")
402+
log.Error(nil, "Workspace owns multiple VirtualServices", "virtualServices", virtualServiceListString)
403+
return r.updateWorkspaceState(ctx, log, workspace,
404+
kubefloworgv1beta1.WorkspaceStateError,
405+
fmt.Sprintf(stateMsgErrorMultipleVirtualServices, virtualServiceListString),
406+
)
407+
case numVirtualServices == 0:
408+
if err := r.Create(ctx, virtualsvc); err != nil {
409+
log.Error(err, "unable to create VirtualService")
410+
return ctrl.Result{}, err
411+
}
412+
VirtualServiceName = virtualsvc.ObjectMeta.Name
413+
log.V(2).Info("VirtualService created", "virtualService", VirtualServiceName)
414+
default:
415+
foundVirtualService := ownedVirtualServices.Items[0]
416+
VirtualServiceName = foundVirtualService.ObjectMeta.Name
417+
if helper.CopyVirtualServiceFields(virtualsvc, foundVirtualService) {
418+
if err := r.Update(ctx, foundVirtualService); err != nil {
419+
if apierrors.IsConflict(err) {
420+
log.V(2).Info("update conflict while updating VirtualService, will requeue")
421+
return ctrl.Result{Requeue: true}, nil
422+
}
423+
log.Error(err, "unable to update VirtualService")
424+
return ctrl.Result{}, err
425+
}
426+
log.V(2).Info("VirtualService updated", "virtualService", VirtualServiceName)
427+
}
428+
}
429+
}
430+
362431
// fetch Pod
363432
// NOTE: the first StatefulSet Pod is always called "{statefulSetName}-0"
364433
podName := fmt.Sprintf("%s-0", statefulSetName)
@@ -423,6 +492,7 @@ func (r *WorkspaceReconciler) SetupWithManager(mgr ctrl.Manager, opts controller
423492
For(&kubefloworgv1beta1.Workspace{}).
424493
Owns(&appsv1.StatefulSet{}).
425494
Owns(&corev1.Service{}).
495+
Owns(&istiov1.VirtualService{}).
426496
Watches(
427497
&kubefloworgv1beta1.WorkspaceKind{},
428498
handler.EnqueueRequestsFromMapFunc(r.mapWorkspaceKindToRequest),
@@ -881,6 +951,82 @@ func generateService(workspace *kubefloworgv1beta1.Workspace, imageConfigSpec ku
881951
return service, nil
882952
}
883953

954+
// generateVirtualService generates a VirtualService for a Workspace
955+
func generateVirtualService(workspace *kubefloworgv1beta1.Workspace, serviceName string, imageConfigSpec kubefloworgv1beta1.ImageConfigSpec) (*istiov1.VirtualService, error) {
956+
// NOTE: the name prefix is used to generate a unique name for the VirtualService
957+
namePrefix := generateNamePrefix(workspace.Name, maxServiceNameLength)
958+
959+
// TODO: Change this to reference podtemplate ports.[].portID
960+
portID := imageConfigSpec.Ports[0].Id
961+
matchUriPrefix := fmt.Sprintf("/workspace/%s/%s/%s/", workspace.Namespace, workspace.Name, portID)
962+
963+
// TODO: Change this to reference podtemplate ports.[].httpProxy.removePathPrefix
964+
rewriteUri := fmt.Sprintf("/workspace/%s/%s/", workspace.Namespace, workspace.Name)
965+
966+
clusterDomain := "cluster.local"
967+
if clusterDomainEnv, ok := os.LookupEnv("CLUSTER_DOMAIN"); ok {
968+
clusterDomain = clusterDomainEnv
969+
}
970+
serviceHost := fmt.Sprintf("%s.%s.svc.%s", serviceName, workspace.Namespace, clusterDomain)
971+
972+
// TODO: Add a possible default for istioGateway
973+
istioGateway := os.Getenv("ISTIO_GATEWAY")
974+
istioHosts := "*"
975+
if istioHostsEnv, ok := os.LookupEnv("ISTIO_HOSTS"); ok {
976+
istioHosts = istioHostsEnv
977+
}
978+
979+
// generate VirtualService
980+
virtualService := &istiov1.VirtualService{
981+
ObjectMeta: metav1.ObjectMeta{
982+
GenerateName: namePrefix,
983+
Namespace: workspace.Namespace,
984+
Labels: map[string]string{
985+
workspaceNameLabel: workspace.Name,
986+
},
987+
},
988+
Spec: networkingv1.VirtualService{
989+
Gateways: []string{istioGateway},
990+
Hosts: []string{istioHosts},
991+
Http: []*networkingv1.HTTPRoute{
992+
{
993+
Headers: &networkingv1.Headers{
994+
Request: &networkingv1.Headers_HeaderOperations{},
995+
},
996+
Match: []*networkingv1.HTTPMatchRequest{
997+
{
998+
Uri: &networkingv1.StringMatch{
999+
MatchType: &networkingv1.StringMatch_Prefix{
1000+
Prefix: matchUriPrefix,
1001+
},
1002+
},
1003+
},
1004+
},
1005+
Route: []*networkingv1.HTTPRouteDestination{
1006+
{
1007+
Destination: &networkingv1.Destination{
1008+
Host: serviceHost,
1009+
Port: &networkingv1.PortSelector{
1010+
Number: uint32(imageConfigSpec.Ports[0].Port), // use the first port as the destination port
1011+
},
1012+
},
1013+
},
1014+
},
1015+
},
1016+
},
1017+
},
1018+
}
1019+
1020+
// set the rewrite URI if it is not empty
1021+
if rewriteUri != "" {
1022+
virtualService.Spec.Http[0].Rewrite = &networkingv1.HTTPRewrite{
1023+
Uri: rewriteUri,
1024+
}
1025+
}
1026+
1027+
return virtualService, nil
1028+
}
1029+
8841030
// generateWorkspaceStatus generates a WorkspaceStatus for a Workspace
8851031
func (r *WorkspaceReconciler) generateWorkspaceStatus(ctx context.Context, log logr.Logger, workspace *kubefloworgv1beta1.Workspace, pod *corev1.Pod, statefulSet *appsv1.StatefulSet) (kubefloworgv1beta1.WorkspaceStatus, error) {
8861032
// NOTE: some fields are populated before this function is called,

workspaces/controller/internal/helper/helper.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package helper
1919
import (
2020
"reflect"
2121

22+
istiov1 "istio.io/client-go/pkg/apis/networking/v1"
2223
appsv1 "k8s.io/api/apps/v1"
2324
corev1 "k8s.io/api/core/v1"
2425
"k8s.io/apimachinery/pkg/api/resource"
@@ -118,6 +119,44 @@ func CopyServiceFields(desired *corev1.Service, target *corev1.Service) bool {
118119
return requireUpdate
119120
}
120121

122+
// CopyVirtualServiceFields updates a target VirtualService with the fields from a desired VirtualService, returning true if an update is required.
123+
func CopyVirtualServiceFields(desired *istiov1.VirtualService, target *istiov1.VirtualService) bool {
124+
requireUpdate := false
125+
126+
// Using the Spec definition https://pkg.go.dev/istio.io/api/networking/v1alpha3alpha3#VirtualService
127+
if !reflect.DeepEqual(target.Spec.Gateways, desired.Spec.Gateways) {
128+
target.Spec.Gateways = desired.Spec.Gateways
129+
requireUpdate = true
130+
}
131+
132+
if !reflect.DeepEqual(target.Spec.Hosts, desired.Spec.Hosts) {
133+
target.Spec.Hosts = desired.Spec.Hosts
134+
requireUpdate = true
135+
}
136+
137+
if !reflect.DeepEqual(target.Spec.Http, desired.Spec.Http) {
138+
target.Spec.Http = desired.Spec.Http
139+
requireUpdate = true
140+
}
141+
142+
if !reflect.DeepEqual(target.Spec.Tls, desired.Spec.Tls) {
143+
target.Spec.Tls = desired.Spec.Tls
144+
requireUpdate = true
145+
}
146+
147+
if !reflect.DeepEqual(target.Spec.Tcp, desired.Spec.Tcp) {
148+
target.Spec.Tcp = desired.Spec.Tcp
149+
requireUpdate = true
150+
}
151+
152+
if !reflect.DeepEqual(target.Spec.ExportTo, desired.Spec.ExportTo) {
153+
target.Spec.ExportTo = desired.Spec.ExportTo
154+
requireUpdate = true
155+
}
156+
157+
return requireUpdate
158+
}
159+
121160
// NormalizePodConfigSpec normalizes a PodConfigSpec so that it can be compared with reflect.DeepEqual
122161
func NormalizePodConfigSpec(spec kubefloworgv1beta1.PodConfigSpec) error {
123162

workspaces/controller/internal/helper/index.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package helper
1919
import (
2020
"context"
2121

22+
istiov1 "istio.io/client-go/pkg/apis/networking/v1"
2223
appsv1 "k8s.io/api/apps/v1"
2324
corev1 "k8s.io/api/core/v1"
2425
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -78,6 +79,21 @@ func SetupManagerFieldIndexers(mgr ctrl.Manager) error {
7879
return err
7980
}
8081

82+
// Index VirtualService by its owner Workspace
83+
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &istiov1.VirtualService{}, IndexWorkspaceOwnerField, func(rawObj client.Object) []string {
84+
virtualService := rawObj.(*istiov1.VirtualService)
85+
owner := metav1.GetControllerOf(virtualService)
86+
if owner == nil {
87+
return nil
88+
}
89+
if owner.APIVersion != kubefloworgv1beta1.GroupVersion.String() || owner.Kind != "Workspace" {
90+
return nil
91+
}
92+
return []string{owner.Name}
93+
}); err != nil {
94+
return err
95+
}
96+
8197
// Index Workspace by WorkspaceKind
8298
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &kubefloworgv1beta1.Workspace{}, IndexWorkspaceKindField, func(rawObj client.Object) []string {
8399
ws := rawObj.(*kubefloworgv1beta1.Workspace)

0 commit comments

Comments
 (0)