-
Notifications
You must be signed in to change notification settings - Fork 147
feat: Add support to run notebooks on its own subdomains in Istio #562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: notebooks-v1
Are you sure you want to change the base?
Changes from all commits
0017ad9
6bbd746
ddf8a25
3295c62
77b5680
4276784
ed4c42b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ package controllers | |
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "reflect" | ||
|
|
@@ -468,7 +469,108 @@ func virtualServiceName(kfName string, namespace string) string { | |
| return fmt.Sprintf("notebook-%s-%s", namespace, kfName) | ||
| } | ||
|
|
||
| func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructured, error) { | ||
| func virtualServiceNameWithSuffix(kfName string, namespace string, suffix string) string { | ||
| return fmt.Sprintf("notebook-%s-%s-%s", namespace, kfName, suffix) | ||
| } | ||
|
|
||
| func generateVirtualServiceForNotebookRedirect(name string, namespace string, prefix string, istioHostNotebook string, istioGateway string) (*unstructured.Unstructured, error) { | ||
| vsvc := &unstructured.Unstructured{} | ||
| vsvc.SetAPIVersion("networking.istio.io/v1alpha3") | ||
| vsvc.SetKind("VirtualService") | ||
| vsvc.SetName(virtualServiceNameWithSuffix(name, namespace, "notebook-redirect")) | ||
| vsvc.SetNamespace(namespace) | ||
|
|
||
| istioHost := os.Getenv("ISTIO_HOST") | ||
| if len(istioHost) == 0 { | ||
| istioHost = "*" | ||
| } | ||
| if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHost}, "spec", "hosts"); err != nil { | ||
| return nil, fmt.Errorf("Set .spec.hosts error: %v", err) | ||
|
|
||
| } | ||
|
|
||
| if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioGateway}, | ||
| "spec", "gateways"); err != nil { | ||
| return nil, fmt.Errorf("set .spec.gateways error: %v", err) | ||
| } | ||
|
|
||
| // the http section of the istio VirtualService spec | ||
| http := []interface{}{ | ||
| map[string]interface{}{ | ||
| "match": []interface{}{ | ||
| map[string]interface{}{ | ||
| "uri": map[string]interface{}{ | ||
| "prefix": prefix, | ||
| }, | ||
| }, | ||
| }, | ||
| "redirect": map[string]interface{}{ | ||
| "authority": istioHostNotebook, | ||
| "redirectCode": int64(307), | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| // add http section to istio VirtualService spec | ||
| if err := unstructured.SetNestedSlice(vsvc.Object, http, "spec", "http"); err != nil { | ||
| return nil, fmt.Errorf("set .spec.http error: %v", err) | ||
| } | ||
|
|
||
| return vsvc, nil | ||
| } | ||
|
|
||
| func generateVirtualServiceForAuthRedirect(name string, namespace string, prefix string, istioHostNotebook string, istioGateway string) (*unstructured.Unstructured, error) { | ||
| vsvc := &unstructured.Unstructured{} | ||
| vsvc.SetAPIVersion("networking.istio.io/v1alpha3") | ||
| vsvc.SetKind("VirtualService") | ||
| vsvc.SetName(virtualServiceNameWithSuffix(name, namespace, "auth-redirect")) | ||
| vsvc.SetNamespace(namespace) | ||
|
|
||
| if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHostNotebook}, "spec", "hosts"); err != nil { | ||
| return nil, fmt.Errorf("set .spec.hosts error: %v", err) | ||
| } | ||
|
|
||
| if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioGateway}, | ||
| "spec", "gateways"); err != nil { | ||
| return nil, fmt.Errorf("set .spec.gateways error: %v", err) | ||
| } | ||
|
|
||
| istioHostAuth := os.Getenv("ISTIO_HOST_AUTH") | ||
| if len(istioHostAuth) == 0 { | ||
| return nil, fmt.Errorf("invalid ISTIO_HOST_AUTH: When ISTIO_USE_NOTEBOOK_SUBDOMAINS is set, the ISTIO_HOST_AUTH environment variable must be defined.") | ||
| } | ||
|
|
||
| istioAuthPath := os.Getenv("ISTIO_AUTH_PATH") | ||
| if len(istioAuthPath) == 0 { | ||
| istioAuthPath = "/oauth2/" | ||
| } | ||
|
|
||
| // the http section of the istio VirtualService spec | ||
| http := []interface{}{ | ||
| map[string]interface{}{ | ||
| "match": []interface{}{ | ||
| map[string]interface{}{ | ||
| "uri": map[string]interface{}{ | ||
| "prefix": istioAuthPath, | ||
| }, | ||
| }, | ||
| }, | ||
| "redirect": map[string]interface{}{ | ||
| "authority": istioHostAuth, | ||
| "redirectCode": int64(307), | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| // add http section to istio VirtualService spec | ||
| if err := unstructured.SetNestedSlice(vsvc.Object, http, "spec", "http"); err != nil { | ||
| return nil, fmt.Errorf("set .spec.http error: %v", err) | ||
| } | ||
|
|
||
| return vsvc, nil | ||
| } | ||
|
|
||
| func generateVirtualServices(instance *v1beta1.Notebook) ([]*unstructured.Unstructured, error) { | ||
| name := instance.Name | ||
| namespace := instance.Namespace | ||
| clusterDomain := "cluster.local" | ||
|
|
@@ -492,20 +594,13 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu | |
| service := fmt.Sprintf("%s.%s.svc.%s", name, namespace, clusterDomain) | ||
|
|
||
| vsvc := &unstructured.Unstructured{} | ||
| vsvcs := []*unstructured.Unstructured{vsvc} | ||
|
|
||
| vsvc.SetAPIVersion("networking.istio.io/v1alpha3") | ||
| vsvc.SetKind("VirtualService") | ||
| vsvc.SetName(virtualServiceName(name, namespace)) | ||
| vsvc.SetNamespace(namespace) | ||
|
|
||
| istioHost := os.Getenv("ISTIO_HOST") | ||
| if len(istioHost) == 0 { | ||
| istioHost = "*" | ||
| } | ||
| if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHost}, "spec", "hosts"); err != nil { | ||
| return nil, fmt.Errorf("Set .spec.hosts error: %v", err) | ||
|
|
||
| } | ||
|
|
||
| istioGateway := os.Getenv("ISTIO_GATEWAY") | ||
| if len(istioGateway) == 0 { | ||
| istioGateway = "kubeflow/kubeflow-gateway" | ||
|
|
@@ -515,6 +610,33 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu | |
| return nil, fmt.Errorf("set .spec.gateways error: %v", err) | ||
| } | ||
|
|
||
| istioHost := os.Getenv("ISTIO_HOST") | ||
| if os.Getenv("ISTIO_USE_NOTEBOOK_SUBDOMAINS") == "true" { | ||
| istioHost = os.Getenv("ISTIO_HOST_NOTEBOOK") | ||
| if !strings.Contains(istioHost, "${NAMESPACE}") { | ||
| return nil, errors.New("invalid ISTIO_HOST_NOTEBOOK: When ISTIO_USE_NOTEBOOK_SUBDOMAINS is set, the placeholder ${NAMESPACE} must be defined in ISTIO_HOST_NOTEBOOK.") | ||
| } | ||
| istioHost = strings.ReplaceAll(istioHost, "${NAMESPACE}", namespace) | ||
|
|
||
| vsvcForNotebookRedirect, err := generateVirtualServiceForNotebookRedirect(name, namespace, prefix, istioHost, istioGateway) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("generate virtual service for notebook redirect error: %v", err) | ||
| } | ||
|
|
||
| vsvcForAuthRedirect, err := generateVirtualServiceForAuthRedirect(name, namespace, prefix, istioHost, istioGateway) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("generate virtual service for auth redirect error: %v", err) | ||
| } | ||
|
|
||
| vsvcs = append(vsvcs, vsvcForAuthRedirect, vsvcForNotebookRedirect) | ||
| } else if len(istioHost) == 0 { | ||
| istioHost = "*" | ||
| } | ||
| if err := unstructured.SetNestedStringSlice(vsvc.Object, []string{istioHost}, "spec", "hosts"); err != nil { | ||
| return nil, fmt.Errorf("Set .spec.hosts error: %v", err) | ||
|
|
||
| } | ||
|
|
||
| headersRequestSet := make(map[string]string) | ||
| // If AnnotationHeadersRequestSet is present, use its values in "headers.request.set" | ||
| if _, ok := annotations[AnnotationHeadersRequestSet]; ok && len(annotations[AnnotationHeadersRequestSet]) > 0 { | ||
|
|
@@ -524,18 +646,28 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu | |
| headersRequestSet = make(map[string]string) | ||
| } | ||
| } | ||
|
|
||
| modifyHeader := func(s string) string { return s } | ||
| if os.Getenv("ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION") == "true" { | ||
| modifyHeader = escapeDynamicEnvoyValueSymbol | ||
| } | ||
| // cast from map[string]string, as SetNestedSlice needs map[string]interface{} | ||
| headersRequestSetInterface := make(map[string]interface{}) | ||
| for key, element := range headersRequestSet { | ||
| headersRequestSetInterface[key] = element | ||
| headersRequestSetInterface[key] = modifyHeader(element) | ||
| } | ||
|
|
||
| // the http section of the istio VirtualService spec | ||
| http := []interface{}{ | ||
| map[string]interface{}{ | ||
| "headers": map[string]interface{}{ | ||
| "request": map[string]interface{}{ | ||
| "set": headersRequestSetInterface, | ||
| // remove the Authorization header to prevent token stealing. | ||
| // otherwise, the bearer token would be forwarded to the notebook container, | ||
| // allowing notebook code to capture a visitor's token. | ||
| "remove": []interface{}{ | ||
| "Authorization", | ||
| }, | ||
|
sven-ernw marked this conversation as resolved.
Comment on lines
+668
to
+670
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is true. Good catch because Istio does not documented this feature.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have added the environment setting ISTIO_DISABLE_ENVOY_HEADER_MANIPULATION to disable envoy's substitution commands syntax in headers. This should be enabled for security reasons because this prevents access token leakage to user-controlled notebooks. It is not enabled by default to avoid breaking changes. What do you think @thesuperzapper @christian-heusel ? See commit: ed4c42b for details. |
||
| }, | ||
| }, | ||
| "match": []interface{}{ | ||
|
|
@@ -566,46 +698,90 @@ func generateVirtualService(instance *v1beta1.Notebook) (*unstructured.Unstructu | |
| return nil, fmt.Errorf("set .spec.http error: %v", err) | ||
| } | ||
|
|
||
| return vsvc, nil | ||
| return vsvcs, nil | ||
|
|
||
| } | ||
|
|
||
| func (r *NotebookReconciler) reconcileVirtualService(instance *v1beta1.Notebook) error { | ||
| log := r.Log.WithValues("notebook", instance.Namespace) | ||
| virtualService, err := generateVirtualService(instance) | ||
| virtualServices, err := generateVirtualServices(instance) | ||
| if err != nil { | ||
| log.Info("Unable to generate VirtualService...", err) | ||
| return err | ||
| } | ||
| if err := ctrl.SetControllerReference(instance, virtualService, r.Scheme); err != nil { | ||
| return err | ||
| } | ||
| // Check if the virtual service already exists. | ||
| foundVirtual := &unstructured.Unstructured{} | ||
| justCreated := false | ||
| foundVirtual.SetAPIVersion("networking.istio.io/v1alpha3") | ||
| foundVirtual.SetKind("VirtualService") | ||
| err = r.Get(context.TODO(), types.NamespacedName{Name: virtualServiceName(instance.Name, | ||
| instance.Namespace), Namespace: instance.Namespace}, foundVirtual) | ||
| if err != nil && apierrs.IsNotFound(err) { | ||
| log.Info("Creating virtual service", "namespace", instance.Namespace, "name", | ||
| virtualServiceName(instance.Name, instance.Namespace)) | ||
| err = r.Create(context.TODO(), virtualService) | ||
| justCreated = true | ||
| if err != nil { | ||
|
|
||
| desiredVirtualServices := make(map[string]struct{}, len(virtualServices)) | ||
|
|
||
| for _, virtualService := range virtualServices { | ||
|
sven-ernw marked this conversation as resolved.
|
||
|
|
||
| if err := ctrl.SetControllerReference(instance, virtualService, r.Scheme); err != nil { | ||
| return err | ||
| } | ||
| } else if err != nil { | ||
| return err | ||
| desiredVirtualServices[virtualService.GetName()] = struct{}{} | ||
|
|
||
| // Check if the virtual service already exists. | ||
| foundVirtual := &unstructured.Unstructured{} | ||
| justCreated := false | ||
| foundVirtual.SetAPIVersion("networking.istio.io/v1alpha3") | ||
| foundVirtual.SetKind("VirtualService") | ||
| virtualServiceName := virtualService.GetName() | ||
| virtualServiceNamespace := virtualService.GetNamespace() | ||
| err = r.Get(context.TODO(), types.NamespacedName{Name: virtualServiceName, Namespace: virtualServiceNamespace}, foundVirtual) | ||
| if err != nil && apierrs.IsNotFound(err) { | ||
| log.Info("Creating virtual service", "namespace", virtualServiceNamespace, "name", | ||
| virtualServiceName) | ||
| err = r.Create(context.TODO(), virtualService) | ||
| justCreated = true | ||
| if err != nil { | ||
| return err | ||
| } | ||
| } else if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| if !justCreated && reconcilehelper.CopyVirtualService(virtualService, foundVirtual) { | ||
| log.Info("Updating virtual service", "namespace", virtualServiceNamespace, "name", | ||
| virtualServiceName) | ||
| err = r.Update(context.TODO(), foundVirtual) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if !justCreated && reconcilehelper.CopyVirtualService(virtualService, foundVirtual) { | ||
| log.Info("Updating virtual service", "namespace", instance.Namespace, "name", | ||
| virtualServiceName(instance.Name, instance.Namespace)) | ||
| err = r.Update(context.TODO(), foundVirtual) | ||
| // Delete redirect VirtualServices that are no longer part of the desired | ||
| // state, for example after ISTIO_USE_NOTEBOOK_SUBDOMAINS is disabled. | ||
| for _, suffix := range []string{"auth-redirect", "notebook-redirect"} { | ||
| name := virtualServiceNameWithSuffix(instance.Name, instance.Namespace, suffix) | ||
| if _, desired := desiredVirtualServices[name]; desired { | ||
| continue | ||
| } | ||
|
|
||
| staleVirtualService := &unstructured.Unstructured{} | ||
| staleVirtualService.SetAPIVersion("networking.istio.io/v1alpha3") | ||
| staleVirtualService.SetKind("VirtualService") | ||
|
|
||
| err := r.Get(context.TODO(), types.NamespacedName{ | ||
| Name: name, | ||
| Namespace: instance.Namespace, | ||
| }, staleVirtualService) | ||
| if apierrs.IsNotFound(err) { | ||
| continue | ||
| } | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| // Never delete a resource with the expected name unless this Notebook is | ||
| // still its controller owner. | ||
| if !metav1.IsControlledBy(staleVirtualService, instance) { | ||
| continue | ||
| } | ||
|
|
||
| log.Info("Deleting stale virtual service", "namespace", instance.Namespace, "name", name) | ||
| if err := r.Delete(context.TODO(), staleVirtualService); err != nil && !apierrs.IsNotFound(err) { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
|
|
@@ -737,3 +913,12 @@ func (r *NotebookReconciler) SetupWithManager(mgr ctrl.Manager) error { | |
|
|
||
| return nil | ||
| } | ||
|
|
||
| // escapeDynamicEnvoyValueSymbol escapes literal percent symbols | ||
| // of envoy's substitution commands syntax in headers. | ||
| // | ||
| // example: "%REQUEST_HEADER(MY_CUSTOM_HEADER)%" becomes "%%REQUEST_HEADER(MY_CUSTOM_HEADER)%%". | ||
| // reference: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#custom-request-response-headers | ||
| func escapeDynamicEnvoyValueSymbol(value string) string { | ||
| return strings.ReplaceAll(value, "%", "%%") | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.