From 9ac17b2446c99117b6d361d0cdfc690b68ac7070 Mon Sep 17 00:00:00 2001 From: mschwab Date: Tue, 4 Aug 2026 11:13:06 -0700 Subject: [PATCH 1/4] fix(studio): serve container-baked assets without config [ASTD-354] The nmp-api image copies the Studio bundle to /static/studio, but the packaged nmp/studio/static dir is only populated by the wheel build, so any non-Helm run of the image (docker run, compose, quickstart) fell through to the "assets are not built" page. Helm masked this by setting studio.static_files_path explicitly. Add /static/studio to the _get_static_files_path fallback chain rather than baking NMP_STUDIO_STATIC_FILES_PATH into the Dockerfile: ServiceConfig derives from EnvironmentFirstSettings, which orders env_settings ahead of init_settings, so the env var would silently override an operator's studio.static_files_path. The fallback also covers future images that lay the bundle down without setting the var. Also split the error page's recovery block. The nvm / make bootstrap-studio instructions only apply to a source checkout; packaged installs now get the NMP_STUDIO_STATIC_FILES_PATH knob and the container bundle location instead. Signed-off-by: mschwab --- docs/set-up/config-reference.mdx | 2 +- services/studio/src/nmp/studio/config.py | 6 +- services/studio/src/nmp/studio/service.py | 69 +++++++++++++++------ services/studio/tests/unit/test_service.py | 72 +++++++++++++++++++++- 4 files changed, 127 insertions(+), 22 deletions(-) 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..af79577548 100644 --- a/services/studio/src/nmp/studio/service.py +++ b/services/studio/src/nmp/studio/service.py @@ -39,6 +39,36 @@ "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
""" + +PACKAGED_INSTALL_TIPS_HTML = f"""

How to fix

+

This is a packaged install, not a source checkout, so there is nothing to build here.

+

Point Studio at a directory that already contains a built bundle:

+
NMP_STUDIO_STATIC_FILES_PATH=/path/to/studio/assets
+

+ The same value can be set as studio.static_files_path in the NeMo Platform + configuration file, but the environment variable takes precedence over it. +

+

+ Official container images ship the bundle at + {escape(str(CONTAINER_STATIC_FILES_PATH))}, which is used automatically when + nothing is configured. +

""" + class StudioService(Service[StudioConfig]): """Studio service for serving the NeMo Studio UI static assets. @@ -252,36 +282,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_TIPS_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 +320,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 +331,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 +346,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..0cddaa2914 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,36 @@ 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 are pointed at the config knob instead of a repo build.""" + 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 "NMP_STUDIO_STATIC_FILES_PATH" in response.text + assert "studio.static_files_path" in response.text + assert "/static/studio" in response.text + assert "make bootstrap-studio" not in response.text + assert "nvm" 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) From 26281a61aa9e0206731b383c0f82ab5eeab1b318 Mon Sep 17 00:00:00 2001 From: mschwab Date: Tue, 4 Aug 2026 11:30:19 -0700 Subject: [PATCH 2/4] fix(docker): fail the Studio UI build instead of shipping a partial dist [ASTD-354] `status="$?"` sat after `fi`, so it captured the exit status of the `if` compound command (always 0 when the condition fails and there is no else) rather than the build's. Every studio build failure therefore fell into `[ 0 != 124 ] && [ 0 != 137 ]` and ran `exit 0`. The RUN succeeded, the three-attempt retry never once fired, and whatever was in dist/ shipped. Vite copies publicDir into outDir from the `vite:prepare-out-dir` renderStart hook, before chunks are rendered, so a build killed during chunk rendering leaves dist/ holding only public/ files. That is exactly what the arm64 nmp-api:0.3.0 image carries at /static/studio: favicon.svg, sample-agents/, sample-datasets/, no index.html, no assets/. The amd64 variant of the same tag is fine, which matches the QEMU hang the existing comment describes. Capture the status with `|| status="$?"` outside the `if`, and assert dist/index.html exists before reporting success so a silently truncated bundle fails the build rather than reaching an image. Signed-off-by: mschwab --- docker/base/Dockerfile.nmp-studio-ui | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docker/base/Dockerfile.nmp-studio-ui b/docker/base/Dockerfile.nmp-studio-ui index 865bd4bce0..a41f16f161 100644 --- a/docker/base/Dockerfile.nmp-studio-ui +++ b/docker/base/Dockerfile.nmp-studio-ui @@ -70,12 +70,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; \ From 05990ef0d650e106bc028a7ba27287dcbc79de24 Mon Sep 17 00:00:00 2001 From: mschwab Date: Tue, 4 Aug 2026 11:50:37 -0700 Subject: [PATCH 3/4] fix(studio): replace packaged-install fix tips with a docs pointer Signed-off-by: mschwab --- services/studio/src/nmp/studio/service.py | 19 +++++-------------- services/studio/tests/unit/test_service.py | 7 +++---- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/services/studio/src/nmp/studio/service.py b/services/studio/src/nmp/studio/service.py index af79577548..27faefb775 100644 --- a/services/studio/src/nmp/studio/service.py +++ b/services/studio/src/nmp/studio/service.py @@ -55,19 +55,10 @@ make bootstrap-studio nemo services restart""" -PACKAGED_INSTALL_TIPS_HTML = f"""

How to fix

-

This is a packaged install, not a source checkout, so there is nothing to build here.

-

Point Studio at a directory that already contains a built bundle:

-
NMP_STUDIO_STATIC_FILES_PATH=/path/to/studio/assets
-

- The same value can be set as studio.static_files_path in the NeMo Platform - configuration file, but the environment variable takes precedence over it. -

-

- Official container images ship the bundle at - {escape(str(CONTAINER_STATIC_FILES_PATH))}, which is used automatically when - nothing is configured. -

""" +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]): @@ -291,7 +282,7 @@ 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_TIPS_HTML + recovery_html = SOURCE_CHECKOUT_TIPS_HTML if source_checkout else PACKAGED_INSTALL_NOTICE_HTML html = f""" diff --git a/services/studio/tests/unit/test_service.py b/services/studio/tests/unit/test_service.py index 0cddaa2914..8346a12219 100644 --- a/services/studio/tests/unit/test_service.py +++ b/services/studio/tests/unit/test_service.py @@ -389,7 +389,7 @@ def test_missing_static_files_route_handles_main_studio_path(self, tmp_path: Pat def test_missing_static_files_route_omits_build_tips_outside_a_checkout( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - """Test that packaged installs are pointed at the config knob instead of a repo build.""" + """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) @@ -402,11 +402,10 @@ def test_missing_static_files_route_omits_build_tips_outside_a_checkout( response = client.get("/studio/") assert response.status_code == 503 - assert "NMP_STUDIO_STATIC_FILES_PATH" in response.text - assert "studio.static_files_path" in response.text - assert "/static/studio" in response.text + 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): From d3ee9ce1994e6ab1d69f5788f0b399321e83c962 Mon Sep 17 00:00:00 2001 From: mschwab Date: Tue, 4 Aug 2026 12:10:20 -0700 Subject: [PATCH 4/4] fix(docker): build the Studio UI on the builder's native arch [ASTD-354] nmp-studio-ui declares platforms = [linux/amd64, linux/arm64], so buildkit ran the whole node + pnpm + vite chain twice and emulated whichever half did not match the builder. dist/ is architecture-independent JS/CSS/HTML, so the second chain bought nothing and supplied the failure mode: emulated arm64 hangs in Vite chunk rendering, which is how nmp-api:0.3.0 shipped an arm64 bundle holding only public/ files. Pin the node stages to $BUILDPLATFORM. The scratch artifacts stage is still stamped per target platform, so nmp-api consumes it unchanged. Verified by building both target platforms on an arm64 host. Before: a full linux/amd64 base chain runs under emulation and dies in `pnpm install` with a Go nil-pointer panic in orval SDK generation, failing the build. After: only native arm64 stages run, and the two exported trees are byte-identical (sha256 d6dd8e84..., 331 assets, index.html 8710 B each). Signed-off-by: mschwab --- docker/base/Dockerfile.nmp-studio-ui | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/base/Dockerfile.nmp-studio-ui b/docker/base/Dockerfile.nmp-studio-ui index a41f16f161..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