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:
1 parent
908157f82e
commit
6f8764099a
4 files changed
+68
-8
No files matched your search
+16
-8
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user