This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new bda3400261 feat(notebook-migration, k8s): deploy the notebook
migration tool on Kubernetes (#8073)
bda3400261 is described below
commit bda3400261d38e674086f3b44cfcae169ccc55e5
Author: Ryan Zhang <[email protected]>
AuthorDate: Wed Sep 9 00:05:53 2026 +0000
feat(notebook-migration, k8s): deploy the notebook migration tool on
Kubernetes (#8073)
### What changes were proposed in this PR?
The notebook migration tool runs in the single-node Docker stack (#7931)
and in local development (#7933), but the Helm chart has no
`notebook-migration-service` and no JupyterLab. A Kubernetes deployment
shows the tool in the workspace with nothing serving its endpoints. This
adds both, following the chart's existing convention that orchestrator
services are global and stateful resources are per user.
`notebook-migration-service` becomes a global Deployment alongside the
other services, with a ServiceAccount whose Role is scoped to the
JupyterLab namespace only. Per-user JupyterLab pods live in their own
namespace behind a headless Service, addressed as
`jupyter-<uid>.<service>.<namespace>.svc.cluster.local`, with a
ResourceQuota bounding the pool and an optional prepull DaemonSet. A
NetworkPolicy stops one user's pod reaching another's. One `values.yaml`
switch, `notebookMigrationService.enabled`, gates the Deployment, the
routes, the pool, and the button in the workspace, and it defaults to
off.
**Enabling the tool now requires a token secret**
`notebookMigrationService.jupyterTokenSecret` has no default. Every
user's JupyterLab token is `HMAC(secret, uid)` and `/jupyter/<uid>/` is
deliberately unauthenticated at the gateway, so a secret shipped in this
repo would be public and would let anyone derive any user's token. The
Secret template wraps the value in `required`, so enabling the tool
without one fails at install with a message naming the key rather than
coming up quietly insecure. Generate one with `openssl rand -hex 32`.
`values-development.yaml` carries an explicitly dev scoped value so the
Minikube flow keeps working.
**How browser traffic reaches the right pod**
This is the design decision worth reviewing closely. JupyterLab is
loaded in an iframe and then issues its own requests for assets,
contents and kernel websockets. None of those can carry a Texera token,
and Texera has no session cookie, so the caller cannot be authenticated
per request. Each user's JupyterLab is therefore served under
`/jupyter/<uid>/`, and the gateway's existing ExtAuthz hook resolves
that uid to the pod's recorded address and rewrites `Host`. It reuses
`dynamic-routes`, its `DynamicResolver` backend, and the
`SecurityPolicy` that already forwards `Host`, so no new gateway
machinery is introduced.
That mechanism routes; it does not authorize. What keeps users apart is
the per-user JupyterLab token from #7665, derived from a server held
secret and unguessable. Anyone who can reach the gateway can route to
any user's pod and will get a 403 from JupyterLab without that user's
token. The NetworkPolicy is defence in depth on top, closing the one
case where a genuinely hostile neighbour runs: another user's pod. This
is a weaker posture than a per-request authorization check, and it is
stated plainly in the code rather than left to be inferred.
Resolution fails closed. A uid too large for an `Int`, a missing
registry row, and a recorded address that will not parse all take one
path to 403, never a 500.
**This PR is not Helm only, unlike what #8006 anticipated**
Four backend changes are inseparable from a working deployment:
* JupyterLab serves under a configurable base path, since routing by
path requires it, and the recorded internal address carries that path
because JupyterLab serves every endpoint under it, `/api` included.
* `access-control-service` gains the Jupyter case in `authorize()`. The
uid regex is built from `kubernetes.jupyter-base-url` and the prefix is
quoted, so a base path is matched as a path and not as a pattern.
* JupyterLab is told which origin may embed it. The setting and the
pod's `TEXERA_ORIGIN` arrived with #8032; this PR passes it to
`--NotebookApp.allow_origin`, without which a proxy that rewrites `Host`
makes JupyterLab's own cross origin check fail on cookie authenticated
requests and kernel startup is blocked entirely.
* The browser facing origin is settable directly, not only derived from
a gateway hostname, because a deployment reached by port forward or
NodePort has no hostname to derive from. When it is derived, the scheme
comes from whether a certificate is configured, `gatewayConfig.issuer`
or `tlsSecretName`, because the chart declares both listeners
unconditionally and a hostname alone does not say which one serves.
`jupyterPool.basePath` is honoured by every layer that reads it: the
pod's `base_url`, the recorded internal URL, the public URL template,
the gateway route match, and the regex `access-control-service` matches
on. All five derive from the one value, so the prefix cannot be obeyed
in one place and ignored in another.
**Rebuilt pods and pooled connections**
A Jupyter pod is named after its uid, so a rebuild reuses the hostname
with a new IP. The gateway keeps pooled upstream connections to the old
address for its default idle hour, and a request handed one of those
hangs until the route timeout, because a departed pod IP is unrouted
rather than refused. A BackendTrafficPolicy sets `connectionIdleTimeout`
to 30s on the dynamic route so those connections are retired promptly.
Measured on the cluster below, a rebuild went from 14 failures in 40
requests scattered over minutes to 4, confined to the moment of the
switch. Computing units share this route and are unaffected either way:
their pods are keyed on a never reused cuid, so no hostname of theirs
ever moves.
**Two routes needed explicit timeouts**
Envoy's default request timeout is 15 seconds, which is shorter than two
things this tool does.
`/api/notebook-migration` now carries a 3m timeout. Provisioning waits
for a pod to terminate and then to answer, up to 60s each, so the
default was truncating work the service would have completed: rebuilds
measured 30 seconds and returned 504 to the browser while the pod came
up fine underneath, with an immediate retry succeeding.
The route serving `/api/chat` and `/api/models` takes its timeout from
`gatewayConfig.llmRequestTimeout`, defaulting to 10m to match
`python-notebook-migration-timeout-minutes`. LLM completions routinely
run past 15 seconds, and the upstream call succeeds and is then
discarded, so every conversion failed while burning the API call. This
also fixes the agent chat, which shares the route.
Known limitations, both worth their own issues:
* JupyterLab pods have no persistent volume, so a pod restart empties
`work/`. Notebooks survive in the database but are not re-uploaded
automatically. The same is true of a single-node container restart, so
this is not specific to Kubernetes.
* A dead pod self-heals on the next request, but an idle one is never
reclaimed.
### Any related issues, documentation, discussions?
Closes #8006
Parent issue #4301
Builds on #7665 (PR #8032, merged), which resolves the Jupyter URL and
token per user. Roadmap context is the architectural note on #5258.
### How was this PR tested?
Unit tests pass across the touched modules: `Config/test` (71),
`AccessControlService/test` (68), `NotebookMigrationService/test` (122),
with `scalafmtCheckAll` clean. `helm template` renders with the feature
on and off; with it disabled the chart emits no Jupyter or
notebook-migration objects at all, and with it enabled but no token
secret the render fails as intended.
Deployed and exercised end to end on a local Minikube cluster running
Calico, chosen because Minikube's default CNI creates NetworkPolicy
objects without enforcing them, which would make the isolation commit
look correct while enforcing nothing.
All seven endpoints were driven through the gateway: `get-jupyter-url`,
`get-jupyter-iframe-url`, `set-notebook`, `delete-notebook`, and the
three mapping endpoints. `set-notebook` was confirmed by reading the
file back through Jupyter's own contents API, and `delete-notebook` by
that read then returning 404. Requests with no token, a garbage token,
and an `INACTIVE` role are refused; `../../etc/x.ipynb`, a non `.ipynb`
name, and malformed JSON bodies are all 400.
Per-user separation was tested with two real users. Each got its own
pod, the quota moved from one to two, and the registry held two rows
with uid scoped addresses. Each served token matched an independent HMAC
derivation of that uid, and each token returned 200 on its own pod and
403 on the other. Routing to a uid with no pod, and to a uid that does
not exist, both return 403.
Isolation was tested with an ablation rather than a single observation.
`jupyter-1` cannot reach `jupyter-2` on 8888 by pod IP or by DNS, in
both directions, while the same pod reaches the `texera-dev` namespace
in the same probe, which rules out broken networking. Removing the
policy makes the connection succeed and restoring it blocks again.
Kernel startup was tested the way a browser does it, with a cookie and
an `Origin` header rather than a token query parameter, because a token
authenticated request skips JupyterLab's origin check and would pass
even when the browser path is broken. `POST /api/sessions` returns 201
and the pod logs `Kernel started`. Cell click sync was confirmed in the
browser.
The failure paths were provoked rather than assumed. Writing an
unparseable address into a registry row returns 403 and logs the
`URISyntaxException`, and 200 returns once restored. Deleting a pod
without touching its row causes the next request to log that the pod no
longer accepts its current token, discard the row and provision a
replacement. Setting `jupyterPool.basePath` to `/lab/notebooks` moves
the pod's `base_url`, the recorded URL, the gateway route and the regex
together, and removing `KUBERNETES_JUPYTER_BASE_URL` from
`access-control-service` restores the break, which is what proves the
wiring rather than the default doing the work.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)
---------
Co-authored-by: Meng Wang <[email protected]>
---
.../service/resource/AccessControlResource.scala | 54 ++++++++-
.../apache/texera/AccessControlResourceSpec.scala | 130 +++++++++++++++++++++
bin/k8s/templates/base/_helpers.tpl | 6 +
.../access-control-service-deployment.yaml | 5 +
.../config-service/config-service-deployment.yaml | 5 +
.../gateway/gateway-jupyter-traffic-policy.yaml | 40 +++++++
bin/k8s/templates/base/gateway/gateway-routes.yaml | 24 ++++
.../base/jupyter-pool/jupyter-namespace.yaml | 22 ++++
.../base/jupyter-pool/jupyter-network-policy.yaml | 49 ++++++++
.../jupyter-pool/jupyter-prepull-daemonset.yaml | 57 +++++++++
.../base/jupyter-pool/jupyter-resource-quota.yaml | 29 +++++
.../base/jupyter-pool/jupyter-service.yaml | 36 ++++++
.../notebook-migration-service-deployment.yaml | 105 +++++++++++++++++
.../notebook-migration-service-secret.yaml | 30 +++++
...notebook-migration-service-service-account.yaml | 49 ++++++++
.../notebook-migration-service-service.yaml | 31 +++++
bin/k8s/values-development.yaml | 24 ++++
bin/k8s/values.yaml | 62 ++++++++++
common/config/src/main/resources/kubernetes.conf | 6 +
.../texera/common/config/KubernetesConfig.scala | 3 +-
.../common/config/KubernetesConfigSpec.scala | 2 +
.../src/main/resources/start-texera-jupyter.sh | 14 ++-
.../service/util/JupyterKubernetesClient.scala | 12 ++
.../texera/service/util/JupyterProvisioner.scala | 4 +-
.../resource/NotebookMigrationResourceSpec.scala | 30 ++++-
.../service/util/JupyterKubernetesClientSpec.scala | 17 +++
26 files changed, 833 insertions(+), 13 deletions(-)
diff --git
a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
index 792a0dfd8a..2d209be59f 100644
---
a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
+++
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
@@ -27,12 +27,12 @@ import jakarta.ws.rs.{Consumes, DELETE, GET, POST, PUT,
Path, Produces}
import org.apache.texera.auth.JwtParser.parseToken
import org.apache.texera.auth.SessionUser
import org.apache.texera.auth.util.{ComputingUnitAccess, HeaderField}
-import org.apache.texera.common.config.{GuiConfig, LLMConfig}
+import org.apache.texera.common.config.{GuiConfig, KubernetesConfig, LLMConfig}
import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
-import
org.apache.texera.dao.jooq.generated.tables.daos.WorkflowComputingUnitDao
+import org.apache.texera.dao.jooq.generated.tables.daos.{UserJupyterDao,
WorkflowComputingUnitDao}
-import java.net.URLDecoder
+import java.net.{URI, URLDecoder}
import java.nio.charset.StandardCharsets
import java.util.Optional
import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsScala}
@@ -51,6 +51,18 @@ object AccessControlResource extends LazyLogging {
private val pvePvesCuidPath: Regex =
"""^/?(?:auth/)?(?:api/|wsapi/)?pve/pves/([0-9]+)$""".r
private val pvePackagesCuidPath: Regex =
"""^/?(?:auth/)?(?:api/|wsapi/)?pve/([0-9]+)/[^/]+/packages/.+$""".r
+ // Per-user JupyterLab. The uid is in the path because a browser cannot
attach Texera
+ // credentials to the requests Jupyter's own scripts make, so it is the only
place the
+ // owner can be read from.
+ private val jupyterPath: Regex =
jupyterPathRegex(KubernetesConfig.jupyterBaseUrl)
+
+ // Built from the same setting the gateway route and the pods are rendered
from, so the
+ // prefix cannot be honoured in one place and not another. Quoted: it is a
path, not a
+ // pattern.
+ private[texera] def jupyterPathRegex(basePath: String): Regex = {
+ val prefix = basePath.stripPrefix("/").stripSuffix("/")
+ ("^/?(?:auth/)?" + Regex.quote(prefix) + "/([0-9]+)(?:/.*)?$").r
+ }
/**
* Authorize the request based on the path and headers.
@@ -68,6 +80,7 @@ object AccessControlResource extends LazyLogging {
logger.info(s"Authorizing request for path: $path")
path match {
+ case jupyterPath(uid) => routeToJupyter(uid)
case wsapiWorkflowWebsocket() | apiExecutionsStats() |
apiExecutionsResultExport() |
pveRoute() =>
checkComputingUnitAccess(uriInfo, headers, bodyOpt)
@@ -77,6 +90,41 @@ object AccessControlResource extends LazyLogging {
}
}
+ /**
+ * Resolve which JupyterLab pod a request belongs to. This routes; it does
not authorize.
+ *
+ * Jupyter is loaded in an iframe and then issues its own requests for
assets, contents and
+ * kernel websockets. None of those can carry a Texera token, and there is
no session cookie
+ * to fall back on, so the caller cannot be authenticated per request. What
protects one
+ * user's notebooks from another is the per-user Jupyter token, which is
derived from a
+ * server-held secret and is unguessable; reaching the right pod without it
yields a 403 from
+ * Jupyter itself. Cross-pod traffic is blocked separately by a
NetworkPolicy.
+ */
+ private def routeToJupyter(uid: String): Response = {
+ // Envoy routes on an authority, so the scheme and base path are stripped
off. The parse
+ // stays inside the guard so a malformed row is denied rather than raised
as a 500.
+ val authority =
+ try {
+ val dao = new
UserJupyterDao(SqlServer.getInstance().createDSLContext().configuration())
+ Option(dao.fetchOneByUid(uid.toInt))
+ .map(row => new URI(row.getInternalUrl).getAuthority)
+ .filter(a => a != null && a.nonEmpty)
+ } catch {
+ case e: Exception =>
+ logger.error(s"Failed to resolve the Jupyter registered for user
$uid", e)
+ return Response.status(Response.Status.FORBIDDEN).build()
+ }
+
+ authority match {
+ case Some(host) =>
+ logger.info(s"Routing Jupyter for user $uid to recorded host: $host")
+ Response.ok().header("Host", host).build()
+ case None =>
+ logger.warn(s"Refusing Jupyter for user $uid: no usable Jupyter
address is registered")
+ Response.status(Response.Status.FORBIDDEN).build()
+ }
+ }
+
private def checkComputingUnitAccess(
uriInfo: UriInfo,
headers: HttpHeaders,
diff --git
a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
index 10ff44db7f..2892a6026a 100644
---
a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
+++
b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
@@ -27,11 +27,13 @@ import org.apache.texera.dao.jooq.generated.enums.{
WorkflowComputingUnitTypeEnum
}
import org.apache.texera.dao.jooq.generated.tables.daos.{
+ UserJupyterDao,
ComputingUnitUserAccessDao,
UserDao,
WorkflowComputingUnitDao
}
import org.apache.texera.dao.jooq.generated.tables.pojos.{
+ UserJupyter,
ComputingUnitUserAccess,
User,
WorkflowComputingUnit
@@ -73,6 +75,11 @@ class AccessControlResourceSpec
private val testNoAccessRecordedUri: String =
"computing-unit-6.compute-unit-svc.default.svc.cluster.local:7777"
+ // What the provisioner records for a user's Jupyter: scheme, authority and
the base path
+ // the pod serves under. Only the authority may reach Envoy as a Host header.
+ private val testJupyterInternalUrl: String =
+
"http://jupyter-1.jupyter-svc.texera-jupyter-pool.svc.cluster.local:8888/jupyter/1"
+
private val testUser1: User = {
val user = new User()
user.setUid(1)
@@ -190,6 +197,14 @@ class AccessControlResourceSpec
readOnlyAccess.setPrivilege(PrivilegeEnum.READ)
computingUnitOfUserDao.insert(readOnlyAccess)
+ // Per-user Jupyter: user 1 has one registered, user 2 deliberately does
not.
+ val jupyterDao = new UserJupyterDao(getDSLContext.configuration())
+ val jupyter = new UserJupyter()
+ jupyter.setUid(testUser1.getUid)
+ jupyter.setInternalUrl(testJupyterInternalUrl)
+ jupyter.setPublicUrl("https://texera.example.com/jupyter/1")
+ jupyterDao.insert(jupyter)
+
token = JwtAuth.jwtToken(JwtAuth.jwtClaims(testUser1))
token2 = JwtAuth.jwtToken(JwtAuth.jwtClaims(testUser2))
}
@@ -722,4 +737,119 @@ class AccessControlResourceSpec
response.getStatus shouldBe Response.Status.OK.getStatusCode
response.getHeaderString("Host") shouldBe testRecordedUri
}
+
+ // -- per-user JupyterLab routing
--------------------------------------------
+
+ it should "route a Jupyter request to the pod recorded for the uid in the
path" in {
+ val (uri, headers) =
mockRequest("/jupyter/1/notebooks/work/notebook.ipynb", None)
+ val response = new AccessControlResource().authorizeGet(uri, headers)
+
+ response.getStatus shouldBe Response.Status.OK.getStatusCode
+ // The scheme and base path are stripped: Envoy routes on an authority
alone.
+ response.getHeaderString("Host") shouldBe
+ "jupyter-1.jupyter-svc.texera-jupyter-pool.svc.cluster.local:8888"
+ }
+
+ it should "route Jupyter's own subrequests, which carry no token" in {
+ // The iframe's asset and API calls cannot present Texera credentials, so
routing has to
+ // work without one. The per-user Jupyter token is what authorizes them.
+ val (uri, headers) =
+ mockRequest("/jupyter/1/api/contents", None, authorizationHeader = None)
+ val response = new AccessControlResource().authorizeGet(uri, headers)
+
+ response.getStatus shouldBe Response.Status.OK.getStatusCode
+ response.getHeaderString("Host") should startWith("jupyter-1.")
+ }
+
+ it should "refuse a Jupyter request for a user with none registered" in {
+ val (uri, headers) = mockRequest("/jupyter/2/tree", None)
+ new AccessControlResource()
+ .authorizeGet(uri, headers)
+ .getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
+
+ it should "refuse a Jupyter request for a uid that does not exist" in {
+ val (uri, headers) = mockRequest("/jupyter/999999/tree", None)
+ new AccessControlResource()
+ .authorizeGet(uri, headers)
+ .getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
+
+ it should "not treat a Jupyter path without a uid as routable" in {
+ // Falls through to the catch-all, which denies.
+ val (uri, headers) = mockRequest("/jupyter/tree", None)
+ new AccessControlResource()
+ .authorizeGet(uri, headers)
+ .getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
+
+ it should "route the gateway-relative form of a Jupyter path" in {
+ val (uri, headers) = mockRequest("auth/jupyter/1/tree", None)
+ new AccessControlResource()
+ .authorizeGet(uri, headers)
+ .getStatus shouldBe Response.Status.OK.getStatusCode
+ }
+
+ it should "refuse a Jupyter uid too large to be a user id" in {
+ // The path regex accepts any run of digits, so the conversion to Int has
to be caught
+ // rather than allowed out as a 500.
+ val (uri, headers) = mockRequest("/jupyter/9999999999/tree", None)
+ new AccessControlResource()
+ .authorizeGet(uri, headers)
+ .getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
+
+ // Registers an address for testUser2, who otherwise has none, for the rows
that should be
+ // refused rather than routed.
+ private def withRecordedJupyter(internalUrl: String)(check: => Unit): Unit =
{
+ val jupyterDao = new UserJupyterDao(getDSLContext.configuration())
+ val row = new UserJupyter()
+ row.setUid(testUser2.getUid)
+ row.setInternalUrl(internalUrl)
+ row.setPublicUrl(internalUrl)
+ jupyterDao.insert(row)
+ try check
+ finally jupyterDao.deleteById(testUser2.getUid)
+ }
+
+ it should "refuse a Jupyter request whose recorded address has no authority"
in {
+ // Parses cleanly but yields a null authority, which must not become a
Host header of
+ // "null" for Envoy to route on.
+ withRecordedJupyter("jupyter-2-with-no-scheme:8888") {
+ val (uri, headers) = mockRequest(s"/jupyter/${testUser2.getUid}/tree",
None)
+ new AccessControlResource()
+ .authorizeGet(uri, headers)
+ .getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
+ }
+
+ it should "build the uid regex from the configured base path" in {
+ val custom = AccessControlResource.jupyterPathRegex("/lab/notebooks")
+ "/lab/notebooks/7/tree" should fullyMatch regex custom
+ "auth/lab/notebooks/7" should fullyMatch regex custom
+ "/jupyter/7/tree" should not(fullyMatch regex custom)
+ }
+
+ it should "tolerate a base path written with surrounding slashes" in {
+ val slashed = AccessControlResource.jupyterPathRegex("/jupyter/")
+ "/jupyter/7/tree" should fullyMatch regex slashed
+ }
+
+ it should "treat the base path as a path, not a pattern" in {
+ // Unquoted, the dot would match any character and route /axb to the /a.b
pool.
+ val dotted = AccessControlResource.jupyterPathRegex("/a.b")
+ "/a.b/7" should fullyMatch regex dotted
+ "/axb/7" should not(fullyMatch regex dotted)
+ }
+
+ it should "refuse a Jupyter request whose recorded address will not parse"
in {
+ // A stray escape throws from the URI constructor. That parse sits inside
the same guard
+ // as the lookup, so this is denied like any other unusable row instead of
raising a 500.
+ withRecordedJupyter("http://jupyter-2:8888/%zz") {
+ val (uri, headers) = mockRequest(s"/jupyter/${testUser2.getUid}/tree",
None)
+ new AccessControlResource()
+ .authorizeGet(uri, headers)
+ .getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+ }
+ }
}
diff --git a/bin/k8s/templates/base/_helpers.tpl
b/bin/k8s/templates/base/_helpers.tpl
index bcb8e33a2d..a4e2b9e130 100644
--- a/bin/k8s/templates/base/_helpers.tpl
+++ b/bin/k8s/templates/base/_helpers.tpl
@@ -77,3 +77,9 @@ values override would let an install quietly hand the
privileged mounter to anot
{{- printf "system:serviceaccount:%s:%s" .Release.Namespace
.Values.accessControlService.serviceAccountName -}}
{{- end -}}
+{{/* Jupyter base path, as exactly one leading slash and no trailing one.
Several places
+append the uid to it, so a bare value would render "http://<origin>jupyter/7".
*/}}
+{{- define "texera.jupyter.basePath" -}}
+{{- printf "/%s" (trimAll "/" .Values.jupyterPool.basePath) -}}
+{{- end -}}
+
diff --git
a/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml
b/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml
index 85fbbdd5f7..62bb8f3b21 100644
---
a/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml
+++
b/bin/k8s/templates/base/access-control-service/access-control-service-deployment.yaml
@@ -55,6 +55,11 @@ spec:
value: {{ .Values.workflowComputingUnitPool.name }}
- name: KUBERNETES_COMPUTE_UNIT_POOL_NAMESPACE
value: {{ .Values.workflowComputingUnitPool.namespace }}
+ {{- if .Values.notebookMigrationService.enabled }}
+ # The uid regex this service routes Jupyter on is built from the
same prefix.
+ - name: KUBERNETES_JUPYTER_BASE_URL
+ value: {{ include "texera.jupyter.basePath" . }}
+ {{- end }}
{{- if .Values.litellm.enabled }}
# LLM gateway used to serve /api/chat and /api/models to the agent
service.
- name: LITELLM_BASE_URL
diff --git
a/bin/k8s/templates/base/config-service/config-service-deployment.yaml
b/bin/k8s/templates/base/config-service/config-service-deployment.yaml
index f0748785c3..d6775483eb 100644
--- a/bin/k8s/templates/base/config-service/config-service-deployment.yaml
+++ b/bin/k8s/templates/base/config-service/config-service-deployment.yaml
@@ -47,6 +47,11 @@ spec:
secretKeyRef:
name: {{ .Release.Name }}-postgresql
key: postgres-password
+ # Shows or hides the notebook migration tool in the workspace.
Derived from the
+ # service's own toggle rather than listed in texeraEnvVars, so
enabling the tool
+ # is one switch instead of two that can disagree.
+ - name: GUI_WORKFLOW_WORKSPACE_PYTHON_NOTEBOOK_MIGRATION_ENABLED
+ value: "{{ .Values.notebookMigrationService.enabled }}"
{{- range .Values.texeraEnvVars }}
- name: {{ .name }}
value: "{{ .value }}"
diff --git a/bin/k8s/templates/base/gateway/gateway-jupyter-traffic-policy.yaml
b/bin/k8s/templates/base/gateway/gateway-jupyter-traffic-policy.yaml
new file mode 100644
index 0000000000..4dd4e809ce
--- /dev/null
+++ b/bin/k8s/templates/base/gateway/gateway-jupyter-traffic-policy.yaml
@@ -0,0 +1,40 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Jupyter pods are named after the uid, so a rebuilt pod reuses its
predecessor's hostname
+# with a new IP. The gateway keeps pooled upstream connections to the old
address for the
+# default idle hour, and requests handed one of those hang until the route
timeout, since a
+# departed pod IP is unrouted rather than refused. Retiring idle connections
quickly bounds
+# that window. Computing units on this route are unaffected either way: their
pods are keyed
+# on a never-reused cuid, so no hostname of theirs ever moves.
+#
+# Active requests hold the connection out of idle, so a running kernel's
websocket is not cut.
+{{- if .Values.notebookMigrationService.enabled }}
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: BackendTrafficPolicy
+metadata:
+ name: {{ .Release.Name }}-jupyter-traffic-policy
+ namespace: {{ .Release.Namespace }}
+spec:
+ targetRefs:
+ - group: gateway.networking.k8s.io
+ kind: HTTPRoute
+ name: {{ .Release.Name }}-dynamic-routes
+ timeout:
+ http:
+ connectionIdleTimeout: 30s
+{{- end }}
diff --git a/bin/k8s/templates/base/gateway/gateway-routes.yaml
b/bin/k8s/templates/base/gateway/gateway-routes.yaml
index f07a3157a4..eb88a2a09d 100644
--- a/bin/k8s/templates/base/gateway/gateway-routes.yaml
+++ b/bin/k8s/templates/base/gateway/gateway-routes.yaml
@@ -74,6 +74,19 @@ spec:
backendRefs:
- name: config-service-svc
port: 9094
+ {{- if .Values.notebookMigrationService.enabled }}
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /api/notebook-migration
+ # Provisioning waits for a pod to terminate and then to answer, 60s
each, so Envoy's
+ # 15s default cuts off requests the service would have completed.
+ timeouts:
+ request: "3m"
+ backendRefs:
+ - name: {{ .Values.notebookMigrationService.name }}-svc
+ port: {{ .Values.notebookMigrationService.service.port }}
+ {{- end }}
- matches:
- path:
type: PathPrefix
@@ -81,6 +94,10 @@ spec:
- path:
type: PathPrefix
value: /api/chat
+ {{- if and .Values.gatewayConfig .Values.gatewayConfig.llmRequestTimeout
}}
+ timeouts:
+ request: {{ .Values.gatewayConfig.llmRequestTimeout | quote }}
+ {{- end }}
backendRefs:
- name: access-control-service-svc
port: 9096
@@ -135,6 +152,13 @@ spec:
- path:
type: PathPrefix
value: /api/pve
+ {{- if .Values.notebookMigrationService.enabled }}
+ # Per-user JupyterLab. ExtAuthz reads the uid from the path and
rewrites Host to
+ # that user's pod; the per-user Jupyter token is what authorizes the
request.
+ - path:
+ type: PathPrefix
+ value: {{ include "texera.jupyter.basePath" . }}
+ {{- end }}
backendRefs:
- group: gateway.envoyproxy.io
kind: Backend
diff --git a/bin/k8s/templates/base/jupyter-pool/jupyter-namespace.yaml
b/bin/k8s/templates/base/jupyter-pool/jupyter-namespace.yaml
new file mode 100644
index 0000000000..9e228d919e
--- /dev/null
+++ b/bin/k8s/templates/base/jupyter-pool/jupyter-namespace.yaml
@@ -0,0 +1,22 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if and .Values.notebookMigrationService.enabled
.Values.jupyterPool.createNamespaces }}
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: {{ .Values.jupyterPool.namespace }}
+{{- end }}
diff --git a/bin/k8s/templates/base/jupyter-pool/jupyter-network-policy.yaml
b/bin/k8s/templates/base/jupyter-pool/jupyter-network-policy.yaml
new file mode 100644
index 0000000000..37d9184957
--- /dev/null
+++ b/bin/k8s/templates/base/jupyter-pool/jupyter-network-policy.yaml
@@ -0,0 +1,49 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if and .Values.notebookMigrationService.enabled
.Values.jupyterPool.networkPolicy.enabled }}
+# Stops one user's JupyterLab from reaching another's, and stops computing
unit pods
+# reaching any of them. Both pools run user code by design and neither has any
reason to
+# call the other, so both are denied. Allowing every other namespace leaves
the real callers
+# working: the notebook migration service, and the Envoy proxy wherever the
gateway
+# installation runs it.
+#
+# Defence in depth, not the authorisation boundary: the per-user Jupyter token
is what stops
+# one user reading another's notebooks. Egress is left alone, since notebooks
legitimately
+# install packages and call out.
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: {{ .Values.jupyterPool.name }}-deny-cross-user
+ namespace: {{ .Values.jupyterPool.namespace }}
+spec:
+ podSelector:
+ matchLabels:
+ type: jupyter
+ policyTypes:
+ - Ingress
+ ingress:
+ - from:
+ # kubernetes.io/metadata.name is set automatically on every namespace,
so the two
+ # pools can be named here without labelling anything by hand.
+ - namespaceSelector:
+ matchExpressions:
+ - key: kubernetes.io/metadata.name
+ operator: NotIn
+ values:
+ - {{ .Values.jupyterPool.namespace }}
+ - {{ .Values.workflowComputingUnitPool.namespace }}
+{{- end }}
diff --git a/bin/k8s/templates/base/jupyter-pool/jupyter-prepull-daemonset.yaml
b/bin/k8s/templates/base/jupyter-pool/jupyter-prepull-daemonset.yaml
new file mode 100644
index 0000000000..e6fca48a27
--- /dev/null
+++ b/bin/k8s/templates/base/jupyter-pool/jupyter-prepull-daemonset.yaml
@@ -0,0 +1,57 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if and .Values.notebookMigrationService.enabled
.Values.jupyterPool.prepullImage }}
+# Pulls the JupyterLab image onto every node ahead of time. Pods are created
on demand and
+# the service waits a bounded time for one to answer, so a first-time pull on
a cold node can
+# outlast that wait and the provisioning attempt is discarded. Mirrors the
computing unit
+# pool's prepuller. Set jupyterPool.prepullImage to false to trade cold starts
for one fewer
+# pod per node.
+apiVersion: apps/v1
+kind: DaemonSet
+metadata:
+ name: {{ .Release.Name }}-jupyter-prepuller
+ namespace: {{ .Release.Namespace }}
+ labels:
+ app: {{ .Release.Name }}-jupyter-prepuller
+spec:
+ selector:
+ matchLabels:
+ app: {{ .Release.Name }}-jupyter-prepuller
+ template:
+ metadata:
+ labels:
+ app: {{ .Release.Name }}-jupyter-prepuller
+ spec:
+ restartPolicy: Always
+ tolerations:
+ - operator: "Exists"
+ initContainers:
+ - name: prepuller
+ image: {{ .Values.texera.imageRegistry }}/{{
.Values.jupyterPool.imageName }}:{{ .Values.texera.imageTag }}
+ imagePullPolicy: {{ .Values.texeraImages.pullPolicy }}
+ command: ["sh", "-c", "true"]
+ containers:
+ - name: pause
+ image: gcr.io/google_containers/pause:3.2
+ resources:
+ limits:
+ cpu: 1m
+ memory: 8Mi
+ requests:
+ cpu: 1m
+ memory: 8Mi
+{{- end }}
diff --git a/bin/k8s/templates/base/jupyter-pool/jupyter-resource-quota.yaml
b/bin/k8s/templates/base/jupyter-pool/jupyter-resource-quota.yaml
new file mode 100644
index 0000000000..7e186b18eb
--- /dev/null
+++ b/bin/k8s/templates/base/jupyter-pool/jupyter-resource-quota.yaml
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if and .Values.notebookMigrationService.enabled
.Values.jupyterPool.createNamespaces .Values.jupyterPool.maxRequestedResources
}}
+# Ceiling for the pool as a whole. Pods are created on demand, one per user,
so without this
+# a busy deployment has no upper bound on what the tool can consume.
+apiVersion: v1
+kind: ResourceQuota
+metadata:
+ name: {{ .Values.jupyterPool.name }}-resource-quota
+ namespace: {{ .Values.jupyterPool.namespace }}
+spec:
+ hard:
+ requests.cpu: "{{ .Values.jupyterPool.maxRequestedResources.cpu }}"
+ requests.memory: {{ .Values.jupyterPool.maxRequestedResources.memory }}
+{{- end }}
diff --git a/bin/k8s/templates/base/jupyter-pool/jupyter-service.yaml
b/bin/k8s/templates/base/jupyter-pool/jupyter-service.yaml
new file mode 100644
index 0000000000..2f0fb1f5e9
--- /dev/null
+++ b/bin/k8s/templates/base/jupyter-pool/jupyter-service.yaml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if .Values.notebookMigrationService.enabled }}
+# Headless, so each user's pod is addressable individually rather than load
balanced across
+# the pool: <pod>.<this service>.<namespace>.svc.cluster.local. The notebook
migration
+# service creates pods whose hostname is the pod name and whose subdomain is
this service's
+# name, which is what makes that address resolve. The selector matches the
"type" label the
+# service stamps on every JupyterLab pod it creates.
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Values.jupyterPool.name }}-svc
+ namespace: {{ .Values.jupyterPool.namespace }}
+spec:
+ clusterIP: None
+ selector:
+ type: jupyter
+ ports:
+ - protocol: TCP
+ port: {{ .Values.jupyterPool.service.port }}
+ targetPort: {{ .Values.jupyterPool.service.targetPort }}
+{{- end }}
diff --git
a/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-deployment.yaml
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-deployment.yaml
new file mode 100644
index 0000000000..2d18f7dc6f
--- /dev/null
+++
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-deployment.yaml
@@ -0,0 +1,105 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if .Values.notebookMigrationService.enabled }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ .Release.Name }}-{{ .Values.notebookMigrationService.name }}
+ namespace: {{ .Release.Namespace }}
+ labels:
+ app: {{ .Release.Name }}-{{ .Values.notebookMigrationService.name }}
+spec:
+ replicas: {{ .Values.notebookMigrationService.numOfPods | default 1 }}
+ selector:
+ matchLabels:
+ app: {{ .Release.Name }}-{{ .Values.notebookMigrationService.name }}
+ template:
+ metadata:
+ labels:
+ app: {{ .Release.Name }}-{{ .Values.notebookMigrationService.name }}
+ spec:
+ # Needed to create and delete each user's JupyterLab pod in the pool
namespace.
+ serviceAccountName: {{
.Values.notebookMigrationService.serviceAccountName }}
+ containers:
+ - name: {{ .Values.notebookMigrationService.name }}
+ image: {{ .Values.texera.imageRegistry }}/{{
.Values.notebookMigrationService.imageName }}:{{ .Values.texera.imageTag }}
+ imagePullPolicy: {{ .Values.texeraImages.pullPolicy }}
+ ports:
+ - containerPort: {{ .Values.notebookMigrationService.service.port
}}
+ env:
+ - name: STORAGE_JDBC_URL
+ value: jdbc:postgresql://{{ .Release.Name
}}-postgresql:5432/texera_db?currentSchema=texera_db,public
+ - name: STORAGE_JDBC_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Release.Name }}-postgresql
+ key: postgres-password
+ # Resolve each user's JupyterLab rather than one shared server.
+ - name: KUBERNETES_JUPYTER_ENABLED
+ value: "true"
+ - name: KUBERNETES_JUPYTER_NAMESPACE
+ value: {{ .Values.jupyterPool.namespace }}
+ - name: KUBERNETES_JUPYTER_SERVICE_NAME
+ value: {{ .Values.jupyterPool.name }}-svc
+ - name: KUBERNETES_JUPYTER_IMAGE_NAME
+ value: {{ .Values.texera.imageRegistry }}/{{
.Values.jupyterPool.imageName }}:{{ .Values.texera.imageTag }}
+ - name: KUBERNETES_JUPYTER_CPU_LIMIT
+ value: "{{ .Values.jupyterPool.resources.cpuLimit }}"
+ - name: KUBERNETES_JUPYTER_MEMORY_LIMIT
+ value: {{ .Values.jupyterPool.resources.memoryLimit }}
+ # The pod's own prefix and the browser-facing address are rendered
from one
+ # basePath, so they cannot drift apart.
+ - name: KUBERNETES_JUPYTER_BASE_URL
+ value: {{ include "texera.jupyter.basePath" . }}
+ {{- $origin := .Values.notebookMigrationService.publicOrigin }}
+ {{- if and (not $origin) .Values.gatewayConfig
.Values.gatewayConfig.hostname }}
+ {{- /* Both listeners always render, so only a configured
certificate says
+ which one actually serves. */}}
+ {{- if or .Values.gatewayConfig.issuer
.Values.gatewayConfig.tlsSecretName }}
+ {{- $origin = printf "https://%s" .Values.gatewayConfig.hostname }}
+ {{- else }}
+ {{- $origin = printf "http://%s" .Values.gatewayConfig.hostname }}
+ {{- end }}
+ {{- end }}
+ {{- if $origin }}
+ - name: KUBERNETES_JUPYTER_PUBLIC_URL_TEMPLATE
+ value: {{ $origin }}{{ include "texera.jupyter.basePath" .
}}/{uid}
+ - name: KUBERNETES_JUPYTER_TEXERA_ORIGIN
+ value: {{ $origin }}
+ {{- end }}
+ - name: JUPYTER_TOKEN_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Release.Name }}-notebook-migration-service-secret
+ key: jupyter-token-secret
+ {{- range .Values.texeraEnvVars }}
+ - name: {{ .name }}
+ value: "{{ .value }}"
+ {{- end }}
+ livenessProbe:
+ httpGet:
+ path: /api/healthcheck
+ port: {{ .Values.notebookMigrationService.service.port }}
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ httpGet:
+ path: /api/healthcheck
+ port: {{ .Values.notebookMigrationService.service.port }}
+ initialDelaySeconds: 5
+ periodSeconds: 5
+{{- end }}
diff --git
a/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-secret.yaml
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-secret.yaml
new file mode 100644
index 0000000000..d9c7f8746a
--- /dev/null
+++
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-secret.yaml
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if .Values.notebookMigrationService.enabled }}
+# Key the per-user JupyterLab tokens are derived from. Kept in a Secret rather
than the
+# deployment's env list because it is a credential: anyone holding it can
derive any user's
+# token.
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ .Release.Name }}-notebook-migration-service-secret
+ namespace: {{ .Release.Namespace }}
+type: Opaque
+{{- $help := "notebookMigrationService.jupyterTokenSecret is required when the
notebook migration tool is enabled: it derives every user's JupyterLab token,
so generate one per deployment, e.g. openssl rand -hex 32" }}
+stringData:
+ jupyter-token-secret: "{{ required $help
.Values.notebookMigrationService.jupyterTokenSecret }}"
+{{- end }}
diff --git
a/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-service-account.yaml
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-service-account.yaml
new file mode 100644
index 0000000000..1b5d37ec3a
--- /dev/null
+++
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-service-account.yaml
@@ -0,0 +1,49 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if .Values.notebookMigrationService.enabled }}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ .Values.notebookMigrationService.serviceAccountName }}
+ namespace: {{ .Release.Namespace }}
+---
+# Scoped to the JupyterLab pool namespace only: the service starts and stops a
user's own
+# notebook server and needs nothing in the release namespace.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: {{ .Values.notebookMigrationService.name }}
+ namespace: {{ .Values.jupyterPool.namespace }}
+rules:
+ - apiGroups: [""]
+ resources: ["pods"]
+ verbs: ["get", "list", "watch", "create", "delete"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ .Values.notebookMigrationService.name }}-binding
+ namespace: {{ .Values.jupyterPool.namespace }}
+subjects:
+ - kind: ServiceAccount
+ name: {{ .Values.notebookMigrationService.serviceAccountName }}
+ namespace: {{ .Release.Namespace }}
+roleRef:
+ kind: Role
+ name: {{ .Values.notebookMigrationService.name }}
+ apiGroup: rbac.authorization.k8s.io
+{{- end }}
diff --git
a/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-service.yaml
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-service.yaml
new file mode 100644
index 0000000000..d13a2d4799
--- /dev/null
+++
b/bin/k8s/templates/base/notebook-migration-service/notebook-migration-service-service.yaml
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+{{- if .Values.notebookMigrationService.enabled }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Values.notebookMigrationService.name }}-svc
+ namespace: {{ .Release.Namespace }}
+spec:
+ type: {{ .Values.notebookMigrationService.service.type }}
+ selector:
+ app: {{ .Release.Name }}-{{ .Values.notebookMigrationService.name }}
+ ports:
+ - protocol: TCP
+ port: {{ .Values.notebookMigrationService.service.port }}
+ targetPort: {{ .Values.notebookMigrationService.service.port }}
+{{- end }}
diff --git a/bin/k8s/values-development.yaml b/bin/k8s/values-development.yaml
index 01fdc2c957..62a3117650 100644
--- a/bin/k8s/values-development.yaml
+++ b/bin/k8s/values-development.yaml
@@ -295,6 +295,30 @@ workflowComputingUnitPool:
port: 8085
targetPort: 8085
+# Notebook migration tool. The base chart ships it off; development turns it
on.
+notebookMigrationService:
+ enabled: true
+ # Reached through a port-forward, so there is no DNS name to derive an
origin from.
+ publicOrigin: "http://localhost:30080"
+ # Development only, and public by virtue of living here. A real deployment
must generate
+ # its own, since this one derives every user's JupyterLab token.
+ jupyterTokenSecret: "development-only-not-a-secret"
+
+# Per-user JupyterLab pods, sized for a single-node development cluster. Only
the values that
+# differ from the base chart are listed, since Helm merges the rest.
+jupyterPool:
+ # The base default of a full CPU and 2Gi per user exhausts a laptop cluster
after two of
+ # them. Limits become requests here, because the pods declare no requests of
their own.
+ resources:
+ cpuLimit: "0.5"
+ memoryLimit: 1Gi
+ # Low enough to actually bind, so a provisioning loop cannot fill the node.
+ maxRequestedResources:
+ cpu: 4
+ memory: 8Gi
+ # One node, so prepulling saves nothing and costs a large image pull at
install time.
+ prepullImage: false
+
texeraEnvVars:
- name: USER_SYS_ADMIN_USERNAME
value: "texera"
diff --git a/bin/k8s/values.yaml b/bin/k8s/values.yaml
index 94f30a5b32..46bb349fca 100644
--- a/bin/k8s/values.yaml
+++ b/bin/k8s/values.yaml
@@ -195,6 +195,61 @@ webserver:
type: ClusterIP
port: 8080
+notebookMigrationService:
+ # Turns the whole notebook migration tool on or off: the service, its route,
the per-user
+ # JupyterLab pool, and the button in the workspace.
+ enabled: false
+ name: notebook-migration-service
+ numOfPods: 1
+ serviceAccountName: notebook-migration-service-service-account
+ imageName: texera-notebook-migration-service
+ service:
+ type: ClusterIP
+ port: 9098
+ # Origin the browser reaches Texera on, used for the JupyterLab iframe URL
and for the
+ # CSP that lets Texera embed it. Required wherever there is no DNS name,
such as a
+ # port-forward or a NodePort. Falls back to the gateway hostname when left
empty.
+ publicOrigin: ""
+ # HMAC key each user's JupyterLab token is derived from. Must stay stable
across restarts
+ # or issued tokens stop matching. Empty on purpose: a default here would be
public, and
+ # the token is all that guards /jupyter/<uid>/. Generate with: openssl rand
-hex 32
+ jupyterTokenSecret: ""
+
+# Per-user JupyterLab pods, the stateful half of the notebook migration tool.
One pod per
+# user, addressed through the headless service below.
+jupyterPool:
+ createNamespaces: true
+ name: texera-jupyter
+ # Note: like the computing unit pool, this namespace can collide when
several Texera
+ # deployments share a cluster.
+ namespace: texera-jupyter-pool
+ imageName: texera-jupyter
+ # Pull the image onto every node ahead of time, so a first-time pull cannot
outlast the
+ # bounded wait for a new pod to answer. Costs one small pod per node.
+ prepullImage: true
+ # Path prefix each user's JupyterLab is served under; the uid is appended,
so user 7 is
+ # served at /jupyter/7/. Drives the pod's base_url, the gateway route and
the uid regex
+ # access-control matches on, so all three move together.
+ basePath: /jupyter
+ # Must match kubernetes.jupyter-port-num, which the service reads from its
own config
+ # and no environment variable overrides.
+ service:
+ port: 8888
+ targetPort: 8888
+ # Per-pod limits.
+ resources:
+ cpuLimit: "1"
+ memoryLimit: 2Gi
+ networkPolicy:
+ # Denies pod-to-pod traffic inside the pool, so one user's JupyterLab
cannot reach
+ # another's. Requires a cluster with a NetworkPolicy controller; without
one the object
+ # is created but not enforced.
+ enabled: true
+ # Ceiling for the pool as a whole.
+ maxRequestedResources:
+ cpu: 50
+ memory: 50Gi
+
workflowComputingUnitManager:
name: workflow-computing-unit-manager
numOfPods: 1
@@ -450,6 +505,13 @@ metrics-server:
gatewayConfig:
# Routes are available at bin/k8s/templates/gateway-routes.yaml
+ # Ceiling for LLM requests through /api/chat and /api/models. Completions
routinely run
+ # for tens of seconds, and Envoy's default route timeout is 15s, which
severs them while
+ # the upstream call is still in flight and then succeeds unseen. Keep this
at or above
+ # GUI_WORKFLOW_WORKSPACE_PYTHON_NOTEBOOK_MIGRATION_TIMEOUT_MINUTES, which is
what the
+ # frontend is prepared to wait.
+ llmRequestTimeout: 10m
+
# The hostname for the Gateway listener (HTTP/HTTPS).
# e.g., "texera.example.com"
hostname: ""
diff --git a/common/config/src/main/resources/kubernetes.conf
b/common/config/src/main/resources/kubernetes.conf
index f423784334..da72b930d3 100644
--- a/common/config/src/main/resources/kubernetes.conf
+++ b/common/config/src/main/resources/kubernetes.conf
@@ -82,6 +82,12 @@ kubernetes {
jupyter-texera-origin = ""
jupyter-texera-origin = ${?KUBERNETES_JUPYTER_TEXERA_ORIGIN}
+ # Prefix each user's Jupyter serves under; the uid is appended, so user 7 is
served at
+ # <prefix>/7/. The gateway reads that uid to pick the pod, so this has to
match the path
+ # component of jupyter-public-url-template.
+ jupyter-base-url = "/jupyter"
+ jupyter-base-url = ${?KUBERNETES_JUPYTER_BASE_URL}
+
jupyter-cpu-limit = "1"
jupyter-cpu-limit = ${?KUBERNETES_JUPYTER_CPU_LIMIT}
diff --git
a/common/config/src/main/scala/org/apache/texera/common/config/KubernetesConfig.scala
b/common/config/src/main/scala/org/apache/texera/common/config/KubernetesConfig.scala
index f52a14fa4e..7cb177c6b6 100644
---
a/common/config/src/main/scala/org/apache/texera/common/config/KubernetesConfig.scala
+++
b/common/config/src/main/scala/org/apache/texera/common/config/KubernetesConfig.scala
@@ -72,9 +72,10 @@ object KubernetesConfig {
val jupyterServiceName: String =
conf.getString("kubernetes.jupyter-service-name")
val jupyterImageName: String =
conf.getString("kubernetes.jupyter-image-name")
val jupyterPortNumber: Int = conf.getInt("kubernetes.jupyter-port-num")
+ val jupyterBaseUrl: String = conf.getString("kubernetes.jupyter-base-url")
+ val jupyterTexeraOrigin: String =
conf.getString("kubernetes.jupyter-texera-origin")
val jupyterCpuLimit: String = conf.getString("kubernetes.jupyter-cpu-limit")
val jupyterMemoryLimit: String =
conf.getString("kubernetes.jupyter-memory-limit")
- val jupyterTexeraOrigin: String =
conf.getString("kubernetes.jupyter-texera-origin")
// Browser-facing address with {uid} substituted; empty means use the
in-network one.
val jupyterPublicUrlTemplate: String =
diff --git
a/common/config/src/test/scala/org/apache/texera/common/config/KubernetesConfigSpec.scala
b/common/config/src/test/scala/org/apache/texera/common/config/KubernetesConfigSpec.scala
index b52568594c..3b3c194b63 100644
---
a/common/config/src/test/scala/org/apache/texera/common/config/KubernetesConfigSpec.scala
+++
b/common/config/src/test/scala/org/apache/texera/common/config/KubernetesConfigSpec.scala
@@ -86,6 +86,8 @@ class KubernetesConfigSpec extends AnyFlatSpec with Matchers {
)
// Empty by default: only a real deployment knows its own origin.
ifUnset("KUBERNETES_JUPYTER_TEXERA_ORIGIN")(KubernetesConfig.jupyterTexeraOrigin
shouldBe "")
+ // A prefix, not a full path: the provisioner appends the uid.
+ ifUnset("KUBERNETES_JUPYTER_BASE_URL")(KubernetesConfig.jupyterBaseUrl
shouldBe "/jupyter")
ifUnset("KUBERNETES_JUPYTER_CPU_LIMIT")(KubernetesConfig.jupyterCpuLimit
shouldBe "1")
ifUnset("KUBERNETES_JUPYTER_MEMORY_LIMIT")(
KubernetesConfig.jupyterMemoryLimit shouldBe "2Gi"
diff --git
a/notebook-migration-service/src/main/resources/start-texera-jupyter.sh
b/notebook-migration-service/src/main/resources/start-texera-jupyter.sh
index 2bfb5a3baf..b38be02604 100644
--- a/notebook-migration-service/src/main/resources/start-texera-jupyter.sh
+++ b/notebook-migration-service/src/main/resources/start-texera-jupyter.sh
@@ -19,14 +19,22 @@
set -euo pipefail
# Texera app origin used by custom.js (postMessage targetOrigin + inbound
origin
-# check) and by the iframe CSP frame-ancestors. Override TEXERA_ORIGIN for
-# deployments under a real hostname; defaults to the local dev origin.
+# check), by the iframe CSP frame-ancestors, and by Jupyter's own cross-origin
check.
+# That last one matters wherever a proxy rewrites the Host header: Jupyter
compares
+# Origin against Host and rejects cookie-authenticated API calls when they
differ, which
+# leaves the notebook without a kernel. Override TEXERA_ORIGIN for deployments
under a
+# real hostname; defaults to the local dev origin.
TEXERA_ORIGIN="${TEXERA_ORIGIN:-http://localhost:4200}"
# Weak default token so the server is not fully open to anyone reachable on the
# published port. The Texera-side iframe URL must pass this through
?token=<value>.
JUPYTER_TOKEN="${JUPYTER_TOKEN:-texera}"
+# Path Jupyter serves under. A deployment that puts every user's Jupyter on
one hostname
+# routes by path, so the server has to know its own prefix. Defaults to "/",
which is what
+# single-node and local dev use.
+JUPYTER_BASE_URL="${JUPYTER_BASE_URL:-/}"
+
# Substitute the origin placeholder in custom.js before the server starts
serving it.
sed -i "s|__TEXERA_ORIGIN__|${TEXERA_ORIGIN}|g"
/home/jovyan/.jupyter/custom/custom.js
@@ -34,5 +42,7 @@ exec start-notebook.sh \
--NotebookApp.token="${JUPYTER_TOKEN}" \
--NotebookApp.password='' \
--NotebookApp.disable_check_xsrf=True \
+ --NotebookApp.allow_origin="${TEXERA_ORIGIN}" \
--NotebookApp.tornado_settings="{'headers': {'Content-Security-Policy':
'frame-ancestors ${TEXERA_ORIGIN}'}}" \
+ --NotebookApp.base_url="${JUPYTER_BASE_URL}" \
--NotebookApp.default_url=/tree
diff --git
a/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterKubernetesClient.scala
b/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterKubernetesClient.scala
index 426912516c..1179de65f5 100644
---
a/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterKubernetesClient.scala
+++
b/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterKubernetesClient.scala
@@ -43,6 +43,14 @@ class JupyterKubernetesClient(client:
io.fabric8.kubernetes.client.KubernetesCli
def generatePodURI(uid: Int): String =
s"${generatePodName(uid)}.${KubernetesConfig.jupyterServiceName}.$namespace.svc.cluster.local:${KubernetesConfig.jupyterPortNumber}"
+ /**
+ * Path a user's Jupyter serves under. The uid is in the path because the
browser cannot
+ * present Texera credentials on the requests Jupyter's own scripts make,
so the gateway
+ * has to read the owner out of the URL instead.
+ */
+ def basePathFor(uid: Int): String =
+
s"/${KubernetesConfig.jupyterBaseUrl.stripPrefix("/").stripSuffix("/")}/$uid"
+
def podExists(uid: Int): Boolean =
getPodByName(generatePodName(uid)).isDefined
def getPodByName(podName: String): Option[Pod] =
@@ -84,6 +92,10 @@ class JupyterKubernetesClient(client:
io.fabric8.kubernetes.client.KubernetesCli
new EnvVarBuilder()
.withName("TEXERA_ORIGIN")
.withValue(KubernetesConfig.jupyterTexeraOrigin)
+ .build(),
+ new EnvVarBuilder()
+ .withName("JUPYTER_BASE_URL")
+ .withValue(s"${basePathFor(uid)}/")
.build()
)
.withResources(resources)
diff --git
a/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterProvisioner.scala
b/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterProvisioner.scala
index 2ba3ee0a18..f0b607e5b5 100644
---
a/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterProvisioner.scala
+++
b/notebook-migration-service/src/main/scala/org/apache/texera/service/util/JupyterProvisioner.scala
@@ -104,7 +104,9 @@ class JupyterProvisioner(
}
private def provision(uid: Int, token: String): Option[JupyterEndpoints] = {
- val internalUrl = s"http://${kubernetes.generatePodURI(uid)}"
+ // Jupyter serves every endpoint under its base path, /api included, so
the recorded
+ // address has to carry it or each later call lands on a 404.
+ val internalUrl =
s"http://${kubernetes.generatePodURI(uid)}${kubernetes.basePathFor(uid)}"
val endpoints = JupyterEndpoints(internalUrl, publicUrlFor(uid,
internalUrl), token)
try {
createIfAbsent(uid, token)
diff --git
a/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
b/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
index 66cbb4406a..e1c1f7ab8e 100644
---
a/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
+++
b/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
@@ -722,7 +722,9 @@ class NotebookMigrationResourceSpec
kubernetes.created.map(_._1) shouldBe List(writerUid.intValue())
registeredUids() shouldBe List(writerUid)
- result.map(_.internalUrl) shouldBe
Some(s"http://${kubernetes.generatePodURI(writerUid)}")
+ result.map(_.internalUrl) shouldBe Some(
+
s"http://${kubernetes.generatePodURI(writerUid)}${kubernetes.basePathFor(writerUid)}"
+ )
}
it should "give the pod the user's own derived token" in {
@@ -752,7 +754,9 @@ class NotebookMigrationResourceSpec
kubernetes.deleted shouldBe List(writerUid.intValue())
kubernetes.created.map(_._1) shouldBe List(writerUid.intValue())
- result.map(_.internalUrl) shouldBe
Some(s"http://${kubernetes.generatePodURI(writerUid)}")
+ result.map(_.internalUrl) shouldBe Some(
+
s"http://${kubernetes.generatePodURI(writerUid)}${kubernetes.basePathFor(writerUid)}"
+ )
}
it should "register nothing and clean up when the pod never becomes ready"
in {
@@ -765,6 +769,18 @@ class NotebookMigrationResourceSpec
registeredUids() shouldBe empty
}
+ it should "record the user's own base path in the internal address" in {
+ // Jupyter serves /api under its base path too, so an address without the
prefix would
+ // make every later probe and contents call 404.
+ val kubernetes = new StubKubernetes
+ val result = provisionerFor(kubernetes, (_, _) => true)
+ .ensure(writerUid, jupyterEnabled = true, tokenSecret = specSecret)
+
+ result.map(_.internalUrl) shouldBe Some(
+
s"http://${kubernetes.generatePodURI(writerUid)}${kubernetes.basePathFor(writerUid)}"
+ )
+ }
+
it should "build the public URL from the configured template" in {
val kubernetes = new StubKubernetes
val result =
@@ -856,7 +872,7 @@ class NotebookMigrationResourceSpec
kubernetes.deleted shouldBe List(writerUid.intValue())
kubernetes.created.map(_._1) shouldBe List(writerUid.intValue())
result.map(_.internalUrl) shouldBe Some(
- s"http://${kubernetes.generatePodURI(writerUid)}"
+
s"http://${kubernetes.generatePodURI(writerUid)}${kubernetes.basePathFor(writerUid)}"
)
}
@@ -871,7 +887,7 @@ class NotebookMigrationResourceSpec
kubernetes.created.map(_._1) shouldBe List(writerUid.intValue())
result.map(_.internalUrl) shouldBe Some(
- s"http://${kubernetes.generatePodURI(writerUid)}"
+
s"http://${kubernetes.generatePodURI(writerUid)}${kubernetes.basePathFor(writerUid)}"
)
}
@@ -908,7 +924,7 @@ class NotebookMigrationResourceSpec
.ensure(writerUid, jupyterEnabled = true, tokenSecret = specSecret)
result.map(_.internalUrl) shouldBe Some(
- s"http://${kubernetes.generatePodURI(writerUid)}"
+
s"http://${kubernetes.generatePodURI(writerUid)}${kubernetes.basePathFor(writerUid)}"
)
kubernetes.deleted shouldBe empty
registeredUids() shouldBe List(writerUid)
@@ -944,7 +960,9 @@ class NotebookMigrationResourceSpec
.ensure(writerUid, jupyterEnabled = true, tokenSecret = specSecret)
kubernetes.created.map(_._1) shouldBe List(writerUid.intValue())
- result.map(_.internalUrl) shouldBe
Some(s"http://${kubernetes.generatePodURI(writerUid)}")
+ result.map(_.internalUrl) shouldBe Some(
+
s"http://${kubernetes.generatePodURI(writerUid)}${kubernetes.basePathFor(writerUid)}"
+ )
}
it should "report unavailable when registration fails for a reason other
than a race" in {
diff --git
a/notebook-migration-service/src/test/scala/org/apache/texera/service/util/JupyterKubernetesClientSpec.scala
b/notebook-migration-service/src/test/scala/org/apache/texera/service/util/JupyterKubernetesClientSpec.scala
index 624ff3ebc3..e86d8c5668 100644
---
a/notebook-migration-service/src/test/scala/org/apache/texera/service/util/JupyterKubernetesClientSpec.scala
+++
b/notebook-migration-service/src/test/scala/org/apache/texera/service/util/JupyterKubernetesClientSpec.scala
@@ -167,6 +167,23 @@ class JupyterKubernetesClientSpec extends AnyFlatSpec with
Matchers {
Some(KubernetesConfig.jupyterTexeraOrigin)
}
+ it should "tell the pod which base path it serves under" in {
+ // The image passes this to --NotebookApp.base_url. Jupyter wants a
trailing slash, and
+ // without the prefix a path-routed deployment serves every endpoint from
the wrong place.
+ val env = createdPod(7,
"tok").getSpec.getContainers.asScala.head.getEnv.asScala
+ env.find(_.getName == "JUPYTER_BASE_URL").map(_.getValue) shouldBe
Some("/jupyter/7/")
+ }
+
+ "basePathFor" should "put the uid in the path so the gateway can read it" in
{
+ // The browser cannot present Texera credentials on the requests Jupyter's
own scripts
+ // make, so the owner has to be recoverable from the URL alone.
+ bare.basePathFor(7) shouldBe "/jupyter/7"
+ }
+
+ it should "give every user a distinct base path" in {
+ (1 to 50).map(bare.basePathFor).distinct.size shouldBe 50
+ }
+
it should "carry the configured image, pull policy and port" in {
val container = createdPod(7, "tok").getSpec.getContainers.asScala.head
container.getImage shouldBe KubernetesConfig.jupyterImageName