Make the image build from a clean clone and actually serve the interface

The Dockerfile copied PRD.md, which is not in the repository, so a clean
clone could not build at all. It also installed node and npm from Ubuntu 24
(node 18) to build a Vite 8 front end that needs node 20.19 or newer, and it
built the interface into a directory nothing served.

The build is now two stages: node:22 builds the interface, the CUDA runtime
image installs the package and carries the built files. The API serves them
when ELY_EYE_UI_DIR points at a directory that exists, and refuses to start
when it is set but empty, naming the build command. The front end already
calls /api/... on its own origin, so same-origin serving needs no client
change.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 01:40:06 -04:00
1 parent 908157f82e
commit 6f8764099a
4 files changed
+68 -8

No files matched your search

+16 -8
View File
@@ -1,26 +1,34 @@
FROM node:22-bookworm-slim AS ui
WORKDIR /ui
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend ./
RUN npm run build
FROM nvidia/cuda:13.3.0-runtime-ubuntu24.04
ENV PYTHONUNBUFFERED=1 \
ELY_EYE_HOME=/data/ely-eye \
ELY_EYE_RUNTIME_BACKEND=sglang \
ELY_EYE_SGLANG_BASE_URL=http://127.0.0.1:8000/v1
ELY_EYE_SGLANG_BASE_URL=http://127.0.0.1:8000/v1 \
ELY_EYE_UI_DIR=/srv/ely-eye/ui
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3.12 python3.12-venv python3-pip nodejs npm ffmpeg \
&& apt-get install -y --no-install-recommends python3.12 python3.12-venv python3-pip ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY backend ./backend
COPY frontend ./frontend
COPY README.md PRD.md ./
COPY README.md ./
RUN python3.12 -m venv /opt/ely-eye \
&& /opt/ely-eye/bin/pip install --upgrade pip setuptools wheel \
&& /opt/ely-eye/bin/pip install -e ./backend \
&& cd frontend \
&& npm install \
&& npm run build
&& /opt/ely-eye/bin/pip install ./backend
COPY --from=ui /ui/dist /srv/ely-eye/ui
EXPOSE 8765
CMD ["/opt/ely-eye/bin/ely-eye", "serve", "--host", "0.0.0.0", "--port", "8765"]
+10
View File
@@ -7,6 +7,7 @@ from typing import Annotated
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import __version__
@@ -339,6 +340,15 @@ def create_app() -> FastAPI:
"cartridges": CartridgeService(settings, db).list_manifests(),
}
mounted_ui = get_settings().ui_dir
if mounted_ui is not None:
if not mounted_ui.is_dir():
raise SystemExit(
f"ui_dir {mounted_ui} is set but holds no built interface; "
"run `npm run build` in frontend/ or unset ELY_EYE_UI_DIR"
)
app.mount("/", StaticFiles(directory=mounted_ui, html=True), name="ui")
return app
+1
View File
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
runtime_backend: Literal["sglang", "transformers"] = "transformers"
sglang_base_url: str = "http://127.0.0.1:8000/v1"
sglang_api_key: str = "ely-eye-local"
ui_dir: Path | None = None
dense_embeddings_enabled: bool = False
live_context_tokens: int = 262_144
extreme_context_tokens: int = 1_010_000
+41
View File
@@ -0,0 +1,41 @@
"""The container story only holds if a built interface is actually served."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ely_eye import api as api_module
from ely_eye.config import Settings
def _client(settings: Settings, monkeypatch) -> TestClient:
monkeypatch.setattr(api_module, "get_settings", lambda: settings)
return TestClient(api_module.create_app())
def test_a_built_interface_is_served_from_the_api_origin(tmp_path: Path, monkeypatch) -> None:
ui = tmp_path / "dist"
ui.mkdir()
(ui / "index.html").write_text("<html><body>ely-eye</body></html>", encoding="utf-8")
settings = Settings(home=tmp_path / "home", ui_dir=ui)
client = _client(settings, monkeypatch)
answer = client.get("/")
assert answer.status_code == 200 and "ely-eye" in answer.text
assert client.get("/api/status").status_code == 200
def test_a_ui_directory_that_was_never_built_names_the_fix(tmp_path: Path, monkeypatch) -> None:
settings = Settings(home=tmp_path / "home", ui_dir=tmp_path / "dist")
monkeypatch.setattr(api_module, "get_settings", lambda: settings)
with pytest.raises(SystemExit, match="npm run build"):
api_module.create_app()
def test_no_ui_directory_means_the_api_alone_answers(tmp_path: Path, monkeypatch) -> None:
settings = Settings(home=tmp_path / "home")
client = _client(settings, monkeypatch)
assert client.get("/").status_code == 404
assert client.get("/api/status").status_code == 200