diff --git a/docker/base/Dockerfile.nmp-studio-ui b/docker/base/Dockerfile.nmp-studio-ui index 865bd4bce0..9481221fe3 100644 --- a/docker/base/Dockerfile.nmp-studio-ui +++ b/docker/base/Dockerfile.nmp-studio-ui @@ -3,7 +3,9 @@ ARG NODE_VERSION=22 ARG DOCKERHUB_MIRROR=docker.io/library # --------------- BASE --------------- # -FROM ${DOCKERHUB_MIRROR}/node:${NODE_VERSION}-bookworm AS base +# dist/ is architecture-independent JS/CSS/HTML, so build it once on the builder's +# native arch. Emulated arm64 builds hang in Vite chunk rendering under QEMU. +FROM --platform=$BUILDPLATFORM ${DOCKERHUB_MIRROR}/node:${NODE_VERSION}-bookworm AS base ENV PUPPETEER_SKIP_DOWNLOAD=true # Preserve the Platform repository layout so SDK generation can resolve @@ -70,12 +72,17 @@ COPY web/packages/studio/env/${ENV_FILE} packages/studio/env/.env # so the Dockerfile can retry before the GitHub job-level timeout cancels the run. RUN set -eu; \ for attempt in 1 2 3; do \ - if VITE_VERSION_SHA="${VITE_VERSION_SHA}" NODE_ENV="${NODE_ENV}" \ + status=0; \ + VITE_VERSION_SHA="${VITE_VERSION_SHA}" NODE_ENV="${NODE_ENV}" \ timeout --kill-after=30s 15m \ - pnpm --filter nemo-studio-ui --fail-if-no-match build:fastapi; then \ + pnpm --filter nemo-studio-ui --fail-if-no-match build:fastapi || status="$?"; \ + if [ "${status}" = "0" ]; then \ + if [ ! -f /app/web/packages/studio/dist/index.html ]; then \ + echo "Studio UI build reported success but dist/index.html is missing"; \ + exit 1; \ + fi; \ exit 0; \ fi; \ - status="$?"; \ if [ "${status}" != "124" ] && [ "${status}" != "137" ]; then \ exit "${status}"; \ fi; \ diff --git a/docs/set-up/config-reference.mdx b/docs/set-up/config-reference.mdx index 6d12bab026..c840696658 100644 --- a/docs/set-up/config-reference.mdx +++ b/docs/set-up/config-reference.mdx @@ -914,7 +914,7 @@ Configuration for the Studio service. ```yaml wordWrap studio: - # Path to the directory containing the built static UI assets. When unset, defaults to the `static/` directory bundled alongside the `nmp.studio` package (populated by the wheel build). + # Path to the directory containing the built static UI assets. When unset, Studio looks for the `static/` directory bundled alongside the `nmp.studio` package (populated by the wheel build), then `/static/studio` (where NeMo Platform container images place the bundle), then `web/packages/studio/dist` in a source checkout. static_files_path: # Base URL of the platform. This is used by the Studio UI to make API calls. | default: '' platform_base_url: '' diff --git a/services/studio/src/nmp/studio/config.py b/services/studio/src/nmp/studio/config.py index 7270a935ae..7876b0f62e 100644 --- a/services/studio/src/nmp/studio/config.py +++ b/services/studio/src/nmp/studio/config.py @@ -62,8 +62,10 @@ class StudioConfig(create_service_config_class("studio")): # type: ignore[misc] default=None, description=( "Path to the directory containing the built static UI assets. " - "When unset, defaults to the `static/` directory bundled alongside the " - "`nmp.studio` package (populated by the wheel build)." + "When unset, Studio looks for the `static/` directory bundled alongside the " + "`nmp.studio` package (populated by the wheel build), then `/static/studio` " + "(where NeMo Platform container images place the bundle), then " + "`web/packages/studio/dist` in a source checkout." ), ) platform_base_url: str = Field( diff --git a/services/studio/src/nmp/studio/service.py b/services/studio/src/nmp/studio/service.py index 7a05c52823..27faefb775 100644 --- a/services/studio/src/nmp/studio/service.py +++ b/services/studio/src/nmp/studio/service.py @@ -39,6 +39,27 @@ "upgrade", } +CONTAINER_STATIC_FILES_PATH = Path("/static/studio") + +SOURCE_CHECKOUT_TIPS_HTML = """

Build tips

+

Run these commands from the repository root.

+

Studio uses the Node.js and pnpm engines in web/package.json.

+

If you use nvm:

+
source ~/.nvm/nvm.sh
+nvm install 22
+nvm use 22
+make bootstrap-studio
+nemo services restart
+

If you use pnpm-managed Node.js:

+
pnpm env use --global 22.18.0
+make bootstrap-studio
+nemo services restart
""" + +DOCS_URL = "https://docs.nvidia.com/nemo-platform" + +PACKAGED_INSTALL_NOTICE_HTML = f"""

This install ships with the Studio bundle, so this is unexpected.

+

See the NeMo Platform documentation for help.

""" + class StudioService(Service[StudioConfig]): """Studio service for serving the NeMo Studio UI static assets. @@ -252,36 +273,29 @@ def _mount_missing_static_files_notice(self, app: FastAPI, static_path: Path) -> @app.get("/studio/", include_in_schema=False) @app.get("/studio/{path:path}", include_in_schema=False) async def studio_static_files_missing(path: str = "") -> HTMLResponse: - return self._missing_static_files_response(static_path, path) + return self._missing_static_files_response( + static_path, path, source_checkout=self._source_static_files_path() is not None + ) @staticmethod - def _missing_static_files_response(static_path: Path, requested_path: str = "") -> HTMLResponse: + def _missing_static_files_response( + static_path: Path, requested_path: str = "", source_checkout: bool = True + ) -> HTMLResponse: route = "/studio" if requested_path == "" else f"/studio/{requested_path}" + recovery_html = SOURCE_CHECKOUT_TIPS_HTML if source_checkout else PACKAGED_INSTALL_NOTICE_HTML html = f""" - NeMo Studio assets are not built + NeMo Studio assets were not found
-

NeMo Studio assets are not built

+

NeMo Studio assets were not found

The platform is running, but Studio cannot be served because the built web assets were not found.

Requested path: {escape(route)}

Expected assets at: {escape(str(static_path))}

-

Build tips

-

Run these commands from the repository root.

-

Studio uses the Node.js and pnpm engines in web/package.json.

-

If you use nvm:

-
source ~/.nvm/nvm.sh
-nvm install 22
-nvm use 22
-make bootstrap-studio
-nemo services restart
-

If you use pnpm-managed Node.js:

-
pnpm env use --global 22.18.0
-make bootstrap-studio
-nemo services restart
+{recovery_html}
@@ -297,7 +311,8 @@ def _get_static_files_path(self) -> Path: Returns: The configured static_files_path from StudioConfig, falling back to the - packaged `static/` directory or source checkout `web/packages/studio/dist`. + packaged `static/` directory, the container image bundle at + `/static/studio`, or source checkout `web/packages/studio/dist`. """ configured = self._get_config().static_files_path if configured is not None: @@ -307,6 +322,10 @@ def _get_static_files_path(self) -> Path: if self._static_assets_ready(packaged_static): return packaged_static + container_static = self._container_static_files_path() + if self._static_assets_ready(container_static): + return container_static + source_static = self._source_static_files_path() if source_static is not None: return source_static @@ -318,6 +337,11 @@ def _packaged_static_files_path() -> Path: """Return the package-local Studio static asset directory.""" return Path(__file__).parent / "static" + @staticmethod + def _container_static_files_path() -> Path: + """Return the Studio bundle location baked into NeMo Platform container images.""" + return CONTAINER_STATIC_FILES_PATH + @staticmethod def _static_assets_ready(path: Path) -> bool: """Return True when a path looks like a built Studio UI bundle.""" diff --git a/services/studio/tests/unit/test_service.py b/services/studio/tests/unit/test_service.py index ab367809ce..8346a12219 100644 --- a/services/studio/tests/unit/test_service.py +++ b/services/studio/tests/unit/test_service.py @@ -229,13 +229,14 @@ def test_same_origin_request_is_allowed(self, monkeypatch: pytest.MonkeyPatch): class TestStaticFilesPath: """Tests for static_files_path configuration.""" - def test_default_static_files_path(self, monkeypatch: pytest.MonkeyPatch): + def test_default_static_files_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Test that the default path is the packaged static dir.""" import nmp.studio expected = Path(nmp.studio.__file__).parent / "static" service = StudioService() monkeypatch.setattr(service, "_source_static_files_path", lambda: None) + monkeypatch.setattr(service, "_container_static_files_path", lambda: tmp_path / "absent") path = service._get_static_files_path() assert path == expected @@ -296,15 +297,59 @@ def test_source_dist_used_when_packaged_static_missing(self, tmp_path: Path, mon service = StudioService() monkeypatch.chdir(source_root) monkeypatch.setattr(service, "_packaged_static_files_path", lambda: packaged_static) + monkeypatch.setattr(service, "_container_static_files_path", lambda: tmp_path / "absent") path = service._get_static_files_path() assert path == studio_dir / "dist" + def test_container_bundle_used_when_packaged_static_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Test that the container image bundle is used when nothing is configured or packaged.""" + container_static = tmp_path / "static" / "studio" + container_static.mkdir(parents=True) + (container_static / "index.html").write_text("") + + service = StudioService() + monkeypatch.setattr(service, "_packaged_static_files_path", lambda: tmp_path / "package-static") + monkeypatch.setattr(service, "_container_static_files_path", lambda: container_static) + + assert service._get_static_files_path() == container_static + + def test_configured_path_wins_over_container_bundle(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Test that an operator's configured path is never shadowed by the container bundle.""" + container_static = tmp_path / "static" / "studio" + container_static.mkdir(parents=True) + (container_static / "index.html").write_text("") + configured = tmp_path / "operator-static" + configured.mkdir() + (configured / "index.html").write_text("") + + service = StudioService().with_config(StudioConfig(static_files_path=configured)) + monkeypatch.setattr(service, "_container_static_files_path", lambda: container_static) + + assert service._get_static_files_path() == configured + + def test_default_container_static_files_path(self): + """Test that the container fallback matches where the images place the bundle.""" + assert StudioService()._container_static_files_path() == Path("/static/studio") + + def test_env_static_files_path_shadows_the_config_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """ServiceConfig is environment-first, so images must not bake NMP_STUDIO_STATIC_FILES_PATH.""" + from nmp.common.config import Configuration + + monkeypatch.setenv("NMP_STUDIO_STATIC_FILES_PATH", str(tmp_path / "from-env")) + + config = Configuration.global_settings_to_service_config( + {"studio": {"static_files_path": str(tmp_path / "from-yaml")}}, StudioConfig + ) + + assert config.static_files_path == tmp_path / "from-env" + def test_missing_static_files_route_explains_recovery(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Test that missing Studio assets return recovery instructions instead of a bare 404.""" missing_static = tmp_path / "missing-static" service = StudioService() monkeypatch.setattr(service, "_get_static_files_path", lambda: missing_static) + monkeypatch.setattr(service, "_source_static_files_path", lambda: tmp_path / "web-dist") app = FastAPI() service.configure_app(app) @@ -327,6 +372,7 @@ def test_missing_static_files_route_handles_main_studio_path(self, tmp_path: Pat missing_static = tmp_path / "missing-static" service = StudioService() monkeypatch.setattr(service, "_get_static_files_path", lambda: missing_static) + monkeypatch.setattr(service, "_source_static_files_path", lambda: tmp_path / "web-dist") app = FastAPI() service.configure_app(app) @@ -340,12 +386,35 @@ def test_missing_static_files_route_handles_main_studio_path(self, tmp_path: Pat assert "make bootstrap-studio" in response.text assert "nemo services restart" in response.text + def test_missing_static_files_route_omits_build_tips_outside_a_checkout( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test that packaged installs get a docs pointer instead of repo build steps.""" + missing_static = tmp_path / "missing-static" + service = StudioService() + monkeypatch.setattr(service, "_get_static_files_path", lambda: missing_static) + monkeypatch.setattr(service, "_source_static_files_path", lambda: None) + app = FastAPI() + + service.configure_app(app) + + client = TestClient(app) + response = client.get("/studio/") + + assert response.status_code == 503 + assert "https://docs.nvidia.com/nemo-platform" in response.text + assert "make bootstrap-studio" not in response.text + assert "nvm" not in response.text + assert "NMP_STUDIO_STATIC_FILES_PATH" not in response.text + assert str(missing_static) in response.text + def test_static_dir_without_index_route_explains_recovery(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Test that an incomplete Studio build also returns recovery instructions.""" incomplete_static = tmp_path / "static" incomplete_static.mkdir() service = StudioService() monkeypatch.setattr(service, "_get_static_files_path", lambda: incomplete_static) + monkeypatch.setattr(service, "_source_static_files_path", lambda: tmp_path / "web-dist") app = FastAPI() service.configure_app(app)