This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-7932-bf61a206e5bdbaf9f68b7f2041814e4a617df5c3
in repository https://gitbox.apache.org/repos/asf/texera.git

commit c4f5389ebd9182f55674eb329f6e41f3b2a0dc1c
Author: Ryan Zhang <[email protected]>
AuthorDate: Tue Aug 25 22:25:33 2026 +0000

    feat(notebook-migration, single-node): deploy the notebook migration 
service and JupyterLab (#7932)
    
    ### What changes were proposed in this PR?
    The notebook migration tool shipped its backend service and workspace
    UI, but nothing deployed them. This PR makes the tool work out of the
    box in the single-node Docker Compose stack: it publishes images for the
    service and for the customized JupyterLab server, runs both in the
    stack, and routes the tool's API traffic through the existing nginx
    proxy.
    
    **`StorageConfig`, `NotebookMigrationResource` (one Jupyter URL becomes
    two)**
    - `storage.jupyter.url` splits into `internal-url` and `public-url`. The
    service calls Jupyter at the internal address (health probes, notebook
    upload and delete); the iframe URL handed to the browser is built from
    the public one. A containerized deployment needs both at once, since the
    in-network service name does not resolve from the browser.
    - `public-url` defaults to `internal-url`, so native local development
    and every existing deployment are unchanged. The old key had a single
    consumer and appeared in no deployment configuration, so no
    compatibility alias is kept.
    - The existing note about this service still targeting one Jupyter per
    process (#7665) is updated to name which URL is which.
    
    **`bin/dockerfiles/notebook-migration-service.dockerfile` (new)**
    - Follows `config-service.dockerfile`, which copies the shared config
    resources the service reads at startup. Omits the `.git` copy that
    exists elsewhere for jgit calls from `OPVersion`: that lives in
    `workflow-core` and `workflow-operator`, which this service does not
    depend on.
    
    **`bin/dockerfiles/jupyter.dockerfile` (new)**
    - Builds the customized JupyterLab image from the files that already
    live with the service, so the image can be published rather than built
    by hand.
    - Replaces `notebook-migration-service/src/main/resources/Dockerfile`
    and the standalone `docker-compose.yml` beside it, both now deleted.
    Every compose file in the repo consumes published images, so the
    per-service compose file had no remaining role once the image is built
    by CI.
    
    **`build-and-push-images.yml`**
    - Adds image name mappings for both new dockerfiles. Without them the
    discovery step falls through to its default and publishes under bare
    names, missing the `texera-` prefix every other image uses.
    
    **`bin/single-node/`**
    - `docker-compose.yml` runs `notebook-migration-service` and `jupyter`,
    the latter publishing its port because the browser loads it directly
    rather than through nginx.
    - The Jupyter container deliberately does not receive `env_file`. Users
    execute arbitrary code there, so it gets only the two values it needs
    instead of the whole environment, which carries the database, S3, lakeFS
    and LiteLLM credentials. This also keeps `JUPYTER_PORT` out of that
    container, which the base image would otherwise read and use to move the
    server off its expected port.
    - `nginx.conf` routes `/api/notebook-migration/` to the service. Longest
    prefix matching gives it priority over the `/api/` catch all.
    - `.env` gains the published port, the Jupyter token, the internal
    Jupyter address, and the GUI toggle for the tool. Values needing
    interpolation are set in the compose file instead, matching how lakeFS
    composes its browser facing presigned endpoint.
    
    **Docs**
    - `bin/single-node/README.md` covers the new port in all three places
    ports are listed, how to use and disable the tool, and a warning that
    all users share one JupyterLab server and one token, which is acceptable
    on a single machine but must not be carried into a multi-user
    deployment.
    
    ### Any related issues, documentation, discussions?
    Closes #7931
    Parent issue #4301
    
    ### How was this PR tested?
    Added a test in `StorageConfigSpec.scala` pinning that the public
    Jupyter URL defaults to the internal one, so splitting them stays a
    no-op outside containerized deployments. The rest of this PR is
    deployment configuration, which the existing suites do not cover.
    
    Existing suites pass: `NotebookMigrationService/test` (42),
    `Config/testOnly StorageConfigSpec`, and
    `bin/single-node/tests/test_single_node_sh.sh`.
    
    Manually verified by running the full stack with `bin/single-node.sh
    up`:
    - All containers reach a healthy state, including both new ones.
    - Authenticated through nginx, then called
    `/api/notebook-migration/get-jupyter-url`, which returned the browser
    reachable address rather than the in-network one. That request also
    confirms the internal direction, since the endpoint only succeeds after
    its reachability probe to Jupyter passes.
    - `POST /api/notebook-migration/set-notebook` through nginx succeeded
    and the notebook landed inside the Jupyter container. The iframe URL
    then returned 200 with the expected `frame-ancestors` header naming the
    Texera origin.
    - Confirmed no credentials are present in the Jupyter container's
    environment.
    - The existing routes (`/api/dataset`, `/api/computing-unit`,
    `/api/models`, `/api/compile`, `/`) are unaffected.
    - With an API key configured, a live model call through the tool's LLM
    path returned successfully.
    
    ### 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]>
---
 .github/workflows/build-and-push-images.yml        |  6 ++
 bin/dockerfiles/jupyter.dockerfile                 | 45 ++++++++++++
 .../notebook-migration-service.dockerfile          | 79 ++++++++++++++++++++++
 bin/single-node/.env                               | 10 +++
 bin/single-node/README.md                          | 18 ++++-
 bin/single-node/docker-compose.yml                 | 48 +++++++++++++
 bin/single-node/nginx.conf                         |  6 ++
 common/config/src/main/resources/storage.conf      |  9 ++-
 .../texera/common/config/StorageConfig.scala       |  6 +-
 .../texera/common/config/StorageConfigSpec.scala   | 11 +++
 .../src/main/resources/Dockerfile                  | 32 ---------
 .../src/main/resources/docker-compose.yml          | 44 ------------
 .../resource/NotebookMigrationResource.scala       | 53 +++++++++------
 .../resource/NotebookMigrationResourceSpec.scala   | 70 +++++++++++++++++++
 14 files changed, 333 insertions(+), 104 deletions(-)

diff --git a/.github/workflows/build-and-push-images.yml 
b/.github/workflows/build-and-push-images.yml
index a4513469dd..cc0d86d8f4 100644
--- a/.github/workflows/build-and-push-images.yml
+++ b/.github/workflows/build-and-push-images.yml
@@ -279,6 +279,12 @@ jobs:
                 "agent-service")
                   image_name="texera-agent-service"
                   ;;
+                "notebook-migration-service")
+                  image_name="texera-notebook-migration-service"
+                  ;;
+                "jupyter")
+                  image_name="texera-jupyter"
+                  ;;
                 *)
                   # Default: use service name as-is
                   image_name="$service"
diff --git a/bin/dockerfiles/jupyter.dockerfile 
b/bin/dockerfiles/jupyter.dockerfile
new file mode 100644
index 0000000000..1f911c5de4
--- /dev/null
+++ b/bin/dockerfiles/jupyter.dockerfile
@@ -0,0 +1,45 @@
+# 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.
+
+# Apache Texera is an effort undergoing incubation at The Apache Software
+# Foundation (ASF), sponsored by the Apache Incubator PMC. Incubation is
+# required of all newly accepted projects until a further review indicates
+# that the infrastructure, communications, and decision-making process have
+# stabilized in a manner consistent with other successful ASF projects.
+# While incubation status is not necessarily a reflection of the
+# completeness or stability of the code, it does indicate that the project
+# has yet to be fully endorsed by the ASF.
+
+FROM jupyter/base-notebook:notebook-6.5.4
+
+# The customizations live with notebook-migration-service, which owns the 
Jupyter
+# integration. Paths are repo-root relative: the build context is the repo 
root.
+COPY notebook-migration-service/src/main/resources/custom.js 
/home/jovyan/.jupyter/custom/custom.js
+COPY notebook-migration-service/src/main/resources/custom.css 
/home/jovyan/.jupyter/custom/custom.css
+COPY notebook-migration-service/src/main/resources/start-texera-jupyter.sh 
/usr/local/bin/start-texera-jupyter.sh
+
+# custom.js must stay writable by jovyan: the startup script substitutes the 
origin
+# placeholder into it at runtime.
+USER root
+RUN chown -R jovyan:users /home/jovyan/.jupyter && \
+    chmod +x /usr/local/bin/start-texera-jupyter.sh
+
+USER jovyan
+
+CMD ["start-texera-jupyter.sh"]
+
+EXPOSE 8888
diff --git a/bin/dockerfiles/notebook-migration-service.dockerfile 
b/bin/dockerfiles/notebook-migration-service.dockerfile
new file mode 100644
index 0000000000..1f45d24ff2
--- /dev/null
+++ b/bin/dockerfiles/notebook-migration-service.dockerfile
@@ -0,0 +1,79 @@
+# 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.
+
+# Apache Texera is an effort undergoing incubation at The Apache Software
+# Foundation (ASF), sponsored by the Apache Incubator PMC. Incubation is
+# required of all newly accepted projects until a further review indicates
+# that the infrastructure, communications, and decision-making process have
+# stabilized in a manner consistent with other successful ASF projects.
+# While incubation status is not necessarily a reflection of the
+# completeness or stability of the code, it does indicate that the project
+# has yet to be fully endorsed by the ASF.
+
+FROM sbtscala/scala-sbt:eclipse-temurin-jammy-17.0.5_8_1.9.3_2.13.11 AS build
+
+# Set working directory
+WORKDIR /texera
+
+# Copy modules for building the service
+COPY common/ common/
+COPY notebook-migration-service/ notebook-migration-service/
+COPY project/ project/
+COPY build.sbt build.sbt
+COPY .jvmopts .jvmopts
+
+# Update system and install dependencies
+RUN apt-get update && apt-get install -y \
+    netcat \
+    unzip \
+    libpq-dev \
+    && apt-get clean
+
+COPY LICENSE NOTICE DISCLAIMER ./
+COPY licenses/ licenses/
+
+RUN sbt clean NotebookMigrationService/dist
+
+# Unzip the texera binary
+RUN unzip 
notebook-migration-service/target/universal/notebook-migration-service-*.zip -d 
target/
+
+FROM eclipse-temurin:17-jre-jammy AS runtime
+
+WORKDIR /texera
+
+# Copy the built texera binary from the build phase
+COPY --from=build /texera/target/notebook-migration-service-* /texera/
+# Copy resources directories from build phase
+COPY --from=build /texera/common/config/src/main/resources 
/texera/common/config/src/main/resources
+COPY --from=build /texera/notebook-migration-service/src/main/resources 
/texera/notebook-migration-service/src/main/resources
+# Copy ASF licensing files. LICENSE-binary and NOTICE-binary describe the
+# bundled third-party contents of this image and ship as /texera/LICENSE
+# and /texera/NOTICE; licenses/ holds the per-license full texts referenced
+# by LICENSE-binary.
+COPY --from=build /texera/notebook-migration-service/LICENSE-binary 
/texera/LICENSE
+COPY --from=build /texera/notebook-migration-service/NOTICE-binary 
/texera/NOTICE
+COPY --from=build /texera/licenses /texera/licenses
+COPY --from=build /texera/DISCLAIMER /texera/
+
+RUN groupadd --system --gid 1001 texera \
+ && useradd --system --uid 1001 --gid texera --home-dir /texera 
--no-create-home texera \
+ && chown -R texera:texera /texera
+USER texera
+
+CMD ["bin/notebook-migration-service"]
+
+EXPOSE 9098
diff --git a/bin/single-node/.env b/bin/single-node/.env
index a6f631ae56..1f38de1002 100644
--- a/bin/single-node/.env
+++ b/bin/single-node/.env
@@ -19,6 +19,7 @@
 TEXERA_HOST=http://localhost
 TEXERA_PORT=8080
 MINIO_PORT=9000
+JUPYTER_PORT=9100
 
 # Log level for all Texera services (valid values: ERROR, WARN, INFO, DEBUG)
 TEXERA_SERVICE_LOG_LEVEL=INFO
@@ -99,3 +100,12 @@ LLM_ENDPOINT=http://nginx:8080
 TEXERA_DASHBOARD_SERVICE_ENDPOINT=http://dashboard-service:8080
 WORKFLOW_COMPILING_SERVICE_ENDPOINT=http://workflow-compiling-service:9090
 
WORKFLOW_EXECUTION_SERVICE_ENDPOINT=http://workflow-runtime-coordinator-service:8085
+
+# Notebook migration tool
+# Toggles the tool in the GUI; the migration itself needs an LLM key above.
+GUI_WORKFLOW_WORKSPACE_PYTHON_NOTEBOOK_MIGRATION_ENABLED=true
+# Weak default token so Jupyter is not fully open on its published port.
+JUPYTER_TOKEN=texera
+# Where notebook-migration-service reaches Jupyter. The browser-facing URL is
+# STORAGE_JUPYTER_PUBLIC_URL, set in docker-compose.yml.
+STORAGE_JUPYTER_INTERNAL_URL=http://texera-jupyter:8888
diff --git a/bin/single-node/README.md b/bin/single-node/README.md
index d9fda11617..17ab8262ca 100644
--- a/bin/single-node/README.md
+++ b/bin/single-node/README.md
@@ -51,16 +51,17 @@ Docker Compose version v2.23.0-desktop.1
 ```
 
 
-By default, Texera services require ports **8080** and **9000** to be free. If 
either port is already in use, the services will fail to start.
+By default, Texera services require ports **8080**, **9000**, and **9100** to 
be free. If any of these ports is already in use, the services will fail to 
start.
 
 On macOS or Linux, run the following commands to check:
 
 ```
 lsof -i :8080
 lsof -i :9000
+lsof -i :9100
 ```
 
-If either command produces output, that port is occupied by another process. 
You will need to either stop that process or change Texera's port 
configuration. See [Advanced Settings > Run Texera on other 
ports](#run-texera-on-other-ports) for instructions.
+If any command produces output, that port is occupied by another process. You 
will need to either stop that process or change Texera's port configuration. 
See [Advanced Settings > Run Texera on other ports](#run-texera-on-other-ports) 
for instructions.
 
 ---
 
@@ -140,6 +141,14 @@ Once Texera is up, create a new workflow and open the 
Texera agent panel at the
 To switch providers or add more LLMs, see [Add more LLMs or 
providers](#add-more-llms-or-providers).
 
 
+## Use the notebook migration tool
+
+The notebook migration tool converts a Jupyter notebook into a Texera 
workflow. It runs a JupyterLab server alongside Texera (published on port 9100) 
and embeds it in the workspace. The conversion itself is powered by an LLM, so 
it needs an API key exactly like [the Texera agent](#enable-the-texera-agent). 
Without one the tool still appears but the conversion fails with a provider 
auth error.
+
+The tool is enabled by default. To turn it off, set 
`GUI_WORKFLOW_WORKSPACE_PYTHON_NOTEBOOK_MIGRATION_ENABLED=false` in the `.env` 
file.
+
+Once Texera is up, go to your workflow list and click the robot button ("AI 
generate a workflow from a Python notebook"). Upload a `.ipynb` file and pick a 
model; Texera generates the workflow and opens it. In the workspace, a Jupyter 
button then appears in the menu bar to expand the notebook alongside the 
generated workflow.
+
 
 ## Advanced Settings
 
@@ -151,10 +160,12 @@ All changes below are to the `.env` file in the 
installation folder, unless othe
 By default, Texera uses:
 - Port 8080 for its web service
 - Port 9000 for its MinIO storage service
+- Port 9100 for the JupyterLab service used by the notebook migration tool
 
 To change these ports, open the `.env` file and update the corresponding 
variables:
 - For the web service port (8080): change `TEXERA_PORT=8080` to your desired 
port, e.g., `TEXERA_PORT=8081`.
 - For the MinIO port (9000): change `MINIO_PORT=9000` to your desired port, 
e.g., `MINIO_PORT=9001`.
+- For the JupyterLab port (9100): change `JUPYTER_PORT=9100` to your desired 
port, e.g., `JUPYTER_PORT=9101`.
 
 ### Change the locations of Texera data
 By default, Docker manages Texera's data locations. To change them to your own 
locations:
@@ -227,11 +238,12 @@ For the full list of supported providers and model IDs, 
see the [LiteLLM proxy c
 
 ### Port conflicts
 
-If Texera fails to start, a common cause is that ports 8080 or 9000 are 
already in use by another application. Check which ports are occupied:
+If Texera fails to start, a common cause is that ports 8080, 9000, or 9100 are 
already in use by another application. Check which ports are occupied:
 
 ```
 lsof -i :8080
 lsof -i :9000
+lsof -i :9100
 ```
 
 Stop the conflicting process, or change Texera's ports following the 
instructions in [Advanced Settings > Run Texera on other 
ports](#run-texera-on-other-ports).
diff --git a/bin/single-node/docker-compose.yml 
b/bin/single-node/docker-compose.yml
index 2f35752392..de6b45420b 100644
--- a/bin/single-node/docker-compose.yml
+++ b/bin/single-node/docker-compose.yml
@@ -470,6 +470,53 @@ services:
       timeout: 3s
       retries: 10
 
+  # NotebookMigrationService provides endpoints for the Jupyter notebook 
migration tool
+  notebook-migration-service:
+    image: 
${IMAGE_REGISTRY:-ghcr.io/apache}/texera-notebook-migration-service:${IMAGE_TAG:-latest}
+    container_name: notebook-migration-service
+    restart: unless-stopped
+    depends_on:
+      postgres:
+        condition: service_healthy
+    env_file:
+      - .env
+    environment:
+      # Interpolation only works in the compose file, not inside .env, so the
+      # browser-facing URL is composed here (same as lakefs's pre-signed 
endpoint).
+      - STORAGE_JUPYTER_PUBLIC_URL=${TEXERA_HOST}:${JUPYTER_PORT:-9100}
+    healthcheck:
+      test: ["CMD", "curl", "-sf", "http://localhost:9098/api/healthcheck";]
+      interval: 5s
+      timeout: 3s
+      retries: 10
+
+  # JupyterLab instance that the notebook migration tool embeds in an iframe.
+  # Its port is published because the browser loads it directly, not through 
nginx
+  # (STORAGE_JUPYTER_PUBLIC_URL); notebook-migration-service reaches it 
in-network
+  # via STORAGE_JUPYTER_INTERNAL_URL.
+  jupyter:
+    image: 
${IMAGE_REGISTRY:-ghcr.io/apache}/texera-jupyter:${IMAGE_TAG:-latest}
+    container_name: texera-jupyter
+    restart: unless-stopped
+    ports:
+      - "${JUPYTER_PORT:-9100}:8888"
+    # No env_file here on purpose. Users run arbitrary code in Jupyter, so this
+    # container gets only the two values it needs, not the whole .env (which 
holds
+    # the DB, S3, LakeFS, and LiteLLM credentials). It also keeps JUPYTER_PORT 
out:
+    # the base image reads it and would move the server off 8888.
+    environment:
+      # Texera app origin for the iframe CSP frame-ancestors and custom.js 
postMessage checks.
+      - TEXERA_ORIGIN=${TEXERA_HOST}:${TEXERA_PORT:-8080}
+      - JUPYTER_TOKEN=${JUPYTER_TOKEN:-texera}
+    healthcheck:
+      # /api returns the server version without requiring the token, so it is a
+      # reliable liveness probe even with auth enabled.
+      test: ["CMD", "python", "-c", "import urllib.request; 
urllib.request.urlopen('http://localhost:8888/api')"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+      start_period: 15s
+
   # Part 3: reverse proxy service for Texera's micro services
   nginx:
     image: nginx:alpine
@@ -483,6 +530,7 @@ services:
       - access-control-service
       - workflow-computing-unit-managing-service
       - agent-service
+      - notebook-migration-service
     volumes:
       - ./nginx.conf:/etc/nginx/nginx.conf:ro
     ports:
diff --git a/bin/single-node/nginx.conf b/bin/single-node/nginx.conf
index c5e500e667..0f091a5cb4 100644
--- a/bin/single-node/nginx.conf
+++ b/bin/single-node/nginx.conf
@@ -81,6 +81,12 @@ http {
             proxy_send_timeout 1d;
         }
 
+        location /api/notebook-migration/ {
+            proxy_pass http://notebook-migration-service:9098;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+        }
+
         location /api/pve/ {
             proxy_pass http://workflow-runtime-coordinator-service:8085;
             proxy_set_header Host $host;
diff --git a/common/config/src/main/resources/storage.conf 
b/common/config/src/main/resources/storage.conf
index b290480843..9af2924901 100644
--- a/common/config/src/main/resources/storage.conf
+++ b/common/config/src/main/resources/storage.conf
@@ -179,10 +179,13 @@ storage {
 
     # Configurations of the JupyterLab service
     jupyter {
-        url = "http://localhost:9100";
-        url = ${?STORAGE_JUPYTER_URL}
+        internal-url = "http://localhost:9100";
+        internal-url = ${?STORAGE_JUPYTER_INTERNAL_URL}
+
+        public-url = ${storage.jupyter.internal-url}
+        public-url = ${?STORAGE_JUPYTER_PUBLIC_URL}
+
         # Read from the same JUPYTER_TOKEN env var as the Jupyter container
-        # (notebook-migration-service/src/main/resources/docker-compose.yml)
         token = "texera"
         token = ${?JUPYTER_TOKEN}
     }
diff --git 
a/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
 
b/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
index 9a98108e19..e48fe4f84e 100644
--- 
a/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
+++ 
b/common/config/src/main/scala/org/apache/texera/common/config/StorageConfig.scala
@@ -155,7 +155,9 @@ object StorageConfig {
   val ENV_S3_AUTH_USERNAME = "STORAGE_S3_AUTH_USERNAME"
   val ENV_S3_AUTH_PASSWORD = "STORAGE_S3_AUTH_PASSWORD"
 
-  // Jupyter
-  val jupyterURL: String = conf.getString("storage.jupyter.url")
+  // Jupyter. Internal for server-side calls, public for the browser iframe. 
Equal
+  // unless a deployment overrides one.
+  val jupyterInternalURL: String = 
conf.getString("storage.jupyter.internal-url")
+  val jupyterPublicURL: String = conf.getString("storage.jupyter.public-url")
   val jupyterToken: String = conf.getString("storage.jupyter.token")
 }
diff --git 
a/common/config/src/test/scala/org/apache/texera/common/config/StorageConfigSpec.scala
 
b/common/config/src/test/scala/org/apache/texera/common/config/StorageConfigSpec.scala
index cfc2461ea6..ac34c46764 100644
--- 
a/common/config/src/test/scala/org/apache/texera/common/config/StorageConfigSpec.scala
+++ 
b/common/config/src/test/scala/org/apache/texera/common/config/StorageConfigSpec.scala
@@ -62,4 +62,15 @@ class StorageConfigSpec extends AnyFlatSpec with Matchers {
   it should "expose the warehouse environment-variable override name" in {
     StorageConfig.ENV_WAREHOUSE_ENABLED shouldBe "STORAGE_WAREHOUSE_ENABLED"
   }
+
+  "StorageConfig jupyter settings" should "default the public URL to the 
internal one" in {
+    // Keeps the split a no-op outside containerized deployments.
+    // Only assert when neither env override is set, since either would win 
otherwise.
+    if (
+      sys.env.get("STORAGE_JUPYTER_INTERNAL_URL").isEmpty &&
+      sys.env.get("STORAGE_JUPYTER_PUBLIC_URL").isEmpty
+    ) {
+      StorageConfig.jupyterPublicURL shouldBe StorageConfig.jupyterInternalURL
+    }
+  }
 }
diff --git a/notebook-migration-service/src/main/resources/Dockerfile 
b/notebook-migration-service/src/main/resources/Dockerfile
deleted file mode 100644
index bea79793f2..0000000000
--- a/notebook-migration-service/src/main/resources/Dockerfile
+++ /dev/null
@@ -1,32 +0,0 @@
-# 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.
-
-FROM jupyter/base-notebook:notebook-6.5.4
-
-# Copy custom JavaScript/CSS for Jupyter and the startup script
-COPY custom.js /home/jovyan/.jupyter/custom/custom.js
-COPY custom.css /home/jovyan/.jupyter/custom/custom.css
-COPY start-texera-jupyter.sh /usr/local/bin/start-texera-jupyter.sh
-
-# Ensure correct permissions. custom.js must stay writable by jovyan so the
-# startup script can substitute the origin placeholder at runtime.
-USER root
-RUN mkdir -p /home/jovyan/.jupyter/custom && \
-    chown -R jovyan:users /home/jovyan/.jupyter && \
-    chmod +x /usr/local/bin/start-texera-jupyter.sh
-
-USER jovyan
diff --git a/notebook-migration-service/src/main/resources/docker-compose.yml 
b/notebook-migration-service/src/main/resources/docker-compose.yml
deleted file mode 100644
index d442a57386..0000000000
--- a/notebook-migration-service/src/main/resources/docker-compose.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-# 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.
-
-name: texera-jupyter
-services:
-
-  jupyter:
-    build:
-      context: .
-      dockerfile: Dockerfile
-    container_name: texera-jupyter
-    restart: unless-stopped
-    ports:
-      - "9100:8888"
-    environment:
-      # Texera app origin, used for the iframe CSP frame-ancestors and the
-      # postMessage origin checks in custom.js. Override for non-local 
deployments.
-      - TEXERA_ORIGIN=http://localhost:4200
-      # Weak default token so the server is not fully open. The Texera-side 
iframe
-      # URL must pass this through ?token=<value>.
-      - JUPYTER_TOKEN=texera
-    command: ["start-texera-jupyter.sh"]
-    healthcheck:
-      # /api returns the server version without requiring the token, so it is a
-      # reliable liveness probe even with auth enabled.
-      test: ["CMD", "python", "-c", "import urllib.request; 
urllib.request.urlopen('http://localhost:8888/api')"]
-      interval: 10s
-      timeout: 5s
-      retries: 5
-      start_period: 15s
diff --git 
a/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
 
b/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
index 3aa0c357c9..f048eda3f3 100644
--- 
a/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
+++ 
b/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
@@ -106,12 +106,20 @@ object NotebookMigrationResource extends LazyLogging {
     }
   }
 
-  // jupyterUrl and jupyterToken are single process-wide values, so this 
service still
-  // targets one Jupyter per process (the per-user-pod model) and must not be 
deployed as a
-  // shared global instance yet: every user would get the same Jupyter and the 
same token.
-  // Resolving these per user is a later stage of the migration (#7665).
-  private val jupyterUrl = StorageConfig.jupyterURL
-  private val jupyterToken = StorageConfig.jupyterToken
+  // The Jupyter server a request targets. internalUrl is what this service 
calls, publicUrl
+  // is what the browser loads; they differ once Jupyter is containerized, 
since the
+  // in-network name does not resolve from the browser. Passed per call so the 
two can be
+  // made distinct, and so per-user resolution (#7665) can build one of these 
per uid.
+  final case class JupyterEndpoints(internalUrl: String, publicUrl: String, 
token: String)
+
+  // Configured default. Process-wide, so this service still targets one 
Jupyter per process
+  // (the per-user-pod model) and must not be deployed as a shared global 
instance yet: every
+  // user would get the same Jupyter and token. Per-user resolution is #7665.
+  private val configuredEndpoints = JupyterEndpoints(
+    StorageConfig.jupyterInternalURL,
+    StorageConfig.jupyterPublicURL,
+    StorageConfig.jupyterToken
+  )
 
   // Default notebook name used when a request does not specify one, so a 
param-less
   // getJupyterIframeURL call reproduces the URL from before this service 
became stateless.
@@ -139,7 +147,10 @@ object NotebookMigrationResource extends LazyLogging {
   }
 
   // Returns the Jupyter iframe reference URL for the given notebook.
-  def getJupyterIframeURL(notebookName: String): Response = {
+  def getJupyterIframeURL(
+      notebookName: String,
+      jupyter: JupyterEndpoints = configuredEndpoints
+  ): Response = {
     // notebookName flows into the returned URL, so validate it the same way 
setNotebook does:
     // block path traversal and keep it to a plain .ipynb filename.
     if (!notebookName.matches("[A-Za-z0-9._-]+\\.ipynb")) {
@@ -149,26 +160,28 @@ object NotebookMigrationResource extends LazyLogging {
         .build()
     }
 
-    if (!isJupyterAvailable(jupyterUrl)) {
+    if (!isJupyterAvailable(jupyter.internalUrl)) {
       return jupyterUnavailableResponse
     }
 
     Response
-      
.ok(successUrlJson(s"$jupyterUrl/notebooks/work/$notebookName?token=$jupyterToken"))
+      .ok(
+        
successUrlJson(s"${jupyter.publicUrl}/notebooks/work/$notebookName?token=${jupyter.token}")
+      )
       .build()
   }
 
   // Returns the URL of Jupyter
-  def getJupyterURL(): Response = {
-    if (!isJupyterAvailable(jupyterUrl)) {
+  def getJupyterURL(jupyter: JupyterEndpoints = configuredEndpoints): Response 
= {
+    if (!isJupyterAvailable(jupyter.internalUrl)) {
       return jupyterUnavailableResponse
     }
 
-    Response.ok(successUrlJson(jupyterUrl)).build()
+    Response.ok(successUrlJson(jupyter.publicUrl)).build()
   }
 
   // Set the notebook in Jupyter
-  def setNotebook(body: String): Response = {
+  def setNotebook(body: String, jupyter: JupyterEndpoints = 
configuredEndpoints): Response = {
     var conn: HttpURLConnection = null
     try {
       val json = parseBody(body) match {
@@ -189,12 +202,12 @@ object NotebookMigrationResource extends LazyLogging {
           .build()
       }
 
-      if (!isJupyterAvailable(jupyterUrl)) {
+      if (!isJupyterAvailable(jupyter.internalUrl)) {
         return jupyterUnavailableResponse
       }
 
       // Construct Jupyter API URL
-      val apiUrl = s"$jupyterUrl/api/contents/work/$notebookName"
+      val apiUrl = s"${jupyter.internalUrl}/api/contents/work/$notebookName"
 
       val url = new URL(apiUrl)
       conn = url.openConnection().asInstanceOf[HttpURLConnection]
@@ -203,7 +216,7 @@ object NotebookMigrationResource extends LazyLogging {
       conn.setDoOutput(true)
       conn.setRequestProperty("Content-Type", "application/json")
       // The Jupyter Contents API requires authentication; send the configured 
token.
-      conn.setRequestProperty("Authorization", s"token $jupyterToken")
+      conn.setRequestProperty("Authorization", s"token ${jupyter.token}")
 
       val requestBody =
         s"""
@@ -258,7 +271,7 @@ object NotebookMigrationResource extends LazyLogging {
   }
 
   // Delete the notebook file from Jupyter's work/ directory:
-  def deleteNotebook(body: String): Response = {
+  def deleteNotebook(body: String, jupyter: JupyterEndpoints = 
configuredEndpoints): Response = {
     var conn: HttpURLConnection = null
     try {
       val json = parseBody(body) match {
@@ -277,17 +290,17 @@ object NotebookMigrationResource extends LazyLogging {
           .build()
       }
 
-      if (!isJupyterAvailable(jupyterUrl)) {
+      if (!isJupyterAvailable(jupyter.internalUrl)) {
         return jupyterUnavailableResponse
       }
 
-      val url = new URL(s"$jupyterUrl/api/contents/work/$notebookName")
+      val url = new 
URL(s"${jupyter.internalUrl}/api/contents/work/$notebookName")
       conn = url.openConnection().asInstanceOf[HttpURLConnection]
 
       conn.setRequestMethod("DELETE")
       conn.setConnectTimeout(2000)
       conn.setReadTimeout(2000)
-      conn.setRequestProperty("Authorization", s"token $jupyterToken")
+      conn.setRequestProperty("Authorization", s"token ${jupyter.token}")
 
       val status = conn.getResponseCode
 
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 09a978efcb..10d5829b87 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
@@ -535,6 +535,76 @@ class NotebookMigrationResourceSpec
     }
   }
 
+  // -- internal vs public Jupyter URL ----------------------------------------
+  // The rest of the suite runs with the configured default, where both URLs 
are
+  // localhost:9100, so it cannot tell the two apart. These pin the split 
itself.
+
+  // Reachable stub for what the service dials; an unroutable address for what 
the browser
+  // gets. 192.0.2.0/24 is TEST-NET-1 (RFC 5737) and routes nowhere, so a call 
that wrongly
+  // dials the public URL fails rather than silently passing. Numeric on 
purpose: a hostname
+  // would go through the resolver, which setConnectTimeout does not bound.
+  private val splitEndpoints = NotebookMigrationResource.JupyterEndpoints(
+    internalUrl = "http://localhost:9100";,
+    publicUrl = "http://192.0.2.1:1234";,
+    token = "texera"
+  )
+
+  "the internal/public URL split" should "dial the internal URL and return 
only the public one" in {
+    withFakeJupyter(contentsStatus = 201) {
+      val urlResp = NotebookMigrationResource.getJupyterURL(splitEndpoints)
+      urlResp.getStatus shouldBe Response.Status.OK.getStatusCode
+      urlResp.getEntity.toString should include("192.0.2.1:1234")
+      urlResp.getEntity.toString should not include "localhost:9100"
+
+      val iframe = 
NotebookMigrationResource.getJupyterIframeURL("notebook.ipynb", splitEndpoints)
+      iframe.getStatus shouldBe Response.Status.OK.getStatusCode
+      iframe.getEntity.toString should include("192.0.2.1:1234")
+      iframe.getEntity.toString should not include "localhost:9100"
+    }
+  }
+
+  it should "send the notebook to the internal URL, not the public one" in {
+    withFakeJupyter(contentsStatus = 201) {
+      val resp = NotebookMigrationResource.setNotebook(
+        """{"notebookName": "notebook.ipynb", "notebookData": {"cells": 
[]}}""",
+        splitEndpoints
+      )
+      // Reaching the stub at all proves the upload used internalUrl: the 
public one is
+      // unroutable, so a swap would surface here as a 500.
+      resp.getStatus shouldBe Response.Status.OK.getStatusCode
+      lastContentsRequest shouldBe Some(("PUT", 
"/api/contents/work/notebook.ipynb"))
+    }
+  }
+
+  it should "delete against the internal URL, not the public one" in {
+    withFakeJupyter(contentsStatus = 204) {
+      val resp = NotebookMigrationResource.deleteNotebook(
+        """{"notebookName": "notebook.ipynb"}""",
+        splitEndpoints
+      )
+      resp.getStatus shouldBe Response.Status.OK.getStatusCode
+      lastContentsRequest shouldBe Some(("DELETE", 
"/api/contents/work/notebook.ipynb"))
+    }
+  }
+
+  it should "report Jupyter unavailable when only the public URL is reachable" 
in {
+    // The inverse of the tests above, and the one that catches the fields 
being swapped:
+    // the reachability probe must follow internalUrl, so a reachable public 
URL must not
+    // rescue an unreachable internal one.
+    withFakeJupyter(contentsStatus = 201) {
+      // Port 9 on loopback: refused immediately, so this fails fast and 
without DNS.
+      val swapped = NotebookMigrationResource.JupyterEndpoints(
+        internalUrl = "http://127.0.0.1:9";,
+        publicUrl = "http://localhost:9100";,
+        token = "texera"
+      )
+      NotebookMigrationResource.getJupyterURL(swapped).getStatus shouldBe 500
+      NotebookMigrationResource
+        .getJupyterIframeURL("notebook.ipynb", swapped)
+        .getStatus shouldBe 500
+    }
+  }
+
   it should "build the iframe URL from an explicit notebook name" in {
     withFakeJupyter(contentsStatus = 201) {
       val resp = resource.getJupyterIframeURL("other.ipynb", 
sessionUser(writerUid))

Reply via email to