Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions docker/base/Dockerfile.nmp-studio-ui
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; \
Expand Down
2 changes: 1 addition & 1 deletion docs/set-up/config-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
Expand Down
6 changes: 4 additions & 2 deletions services/studio/src/nmp/studio/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
60 changes: 42 additions & 18 deletions services/studio/src/nmp/studio/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@
"upgrade",
}

CONTAINER_STATIC_FILES_PATH = Path("/static/studio")

SOURCE_CHECKOUT_TIPS_HTML = """ <h2>Build tips</h2>
<p>Run these commands from the repository root.</p>
<p>Studio uses the Node.js and pnpm engines in <code>web/package.json</code>.</p>
<p>If you use nvm:</p>
<pre>source ~/.nvm/nvm.sh
nvm install 22
nvm use 22
make bootstrap-studio
nemo services restart</pre>
<p>If you use pnpm-managed Node.js:</p>
<pre>pnpm env use --global 22.18.0
make bootstrap-studio
nemo services restart</pre>"""

DOCS_URL = "https://docs.nvidia.com/nemo-platform"

PACKAGED_INSTALL_NOTICE_HTML = f""" <p>This install ships with the Studio bundle, so this is unexpected.</p>
<p>See the <a href="{DOCS_URL}">NeMo Platform documentation</a> for help.</p>"""


class StudioService(Service[StudioConfig]):
"""Studio service for serving the NeMo Studio UI static assets.
Expand Down Expand Up @@ -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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>NeMo Studio assets are not built</title>
<title>NeMo Studio assets were not found</title>
</head>
<body>
<main>
<h1>NeMo Studio assets are not built</h1>
<h1>NeMo Studio assets were not found</h1>
<p>The platform is running, but Studio cannot be served because the built web assets were not found.</p>
<p>Requested path: <code>{escape(route)}</code></p>
<p>Expected assets at: <code>{escape(str(static_path))}</code></p>
<h2>Build tips</h2>
<p>Run these commands from the repository root.</p>
<p>Studio uses the Node.js and pnpm engines in <code>web/package.json</code>.</p>
<p>If you use nvm:</p>
<pre>source ~/.nvm/nvm.sh
nvm install 22
nvm use 22
make bootstrap-studio
nemo services restart</pre>
<p>If you use pnpm-managed Node.js:</p>
<pre>pnpm env use --global 22.18.0
make bootstrap-studio
nemo services restart</pre>
{recovery_html}
</main>
</body>
</html>
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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."""
Expand Down
71 changes: 70 additions & 1 deletion services/studio/tests/unit/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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("<html></html>")

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("<html></html>")
configured = tmp_path / "operator-static"
configured.mkdir()
(configured / "index.html").write_text("<html></html>")

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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading