diff --git a/.dockerignore b/.dockerignore index 3dd16a1..1557afd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,5 @@ __pycache__ *.sqlite3 tasks console/node_modules +cloud-console/node_modules +cloud-console/dist diff --git a/Dockerfile b/Dockerfile index 733be98..5ed4a30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,33 @@ -FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim +# syntax=docker/dockerfile:1 +# +# Build args let CI inject mirrors for faster builds in CN networks; the +# defaults keep the Dockerfile portable so anyone can `docker build .` +# without extra configuration. +# +# NODE_IMAGE – stage 1 base (Docker Hub library/node) +# NPM_REGISTRY – npm registry for `npm ci` +# UV_IMAGE – stage 2 base (ghcr.io/astral-sh/uv) +# APT_MIRROR – Debian apt mirror host (e.g. mirrors.aliyun.com); empty = official +# UV_INDEX_URL – PyPI index URL passed through to `uv sync`; empty = official + +# Stage 1: build the cloud-console Vue 3 SPA. +# NOTE: do not set NODE_ENV=production here — vue-tsc and typescript are +# devDependencies required by `npm run build`. +ARG NODE_IMAGE=node:20-bookworm-slim +ARG NPM_REGISTRY=https://registry.npmjs.org +FROM ${NODE_IMAGE} AS frontend +# Re-declare inside the stage so --build-arg values (or the global default) +# are visible to RUN. Without this, ARGs declared before FROM are inaccessible. +ARG NPM_REGISTRY +WORKDIR /app +COPY cloud-console/package.json cloud-console/package-lock.json ./ +RUN npm ci --registry=${NPM_REGISTRY} +COPY cloud-console/ ./ +RUN npm run build + +# Stage 2: the existing Python image, now carrying the SPA build output. +ARG UV_IMAGE=ghcr.io/astral-sh/uv:python3.14-bookworm-slim +FROM ${UV_IMAGE} ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -7,10 +36,26 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +ARG APT_MIRROR= +# bookworm uses /etc/apt/sources.list.d/debian.sources (DEB822 format); older +# Debian releases use /etc/apt/sources.list. Patch whichever exists. +RUN if [ -n "$APT_MIRROR" ]; then \ + if [ -f /etc/apt/sources.list.d/debian.sources ]; then \ + sed -i "s|deb.debian.org|$APT_MIRROR|g; s|security.debian.org|$APT_MIRROR|g" \ + /etc/apt/sources.list.d/debian.sources; \ + elif [ -f /etc/apt/sources.list ]; then \ + sed -i "s|deb.debian.org|$APT_MIRROR|g; s|security.debian.org|$APT_MIRROR|g" \ + /etc/apt/sources.list; \ + fi; \ + fi \ + && apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* +COPY --from=frontend /app/dist /app/console-static COPY . . + +ARG UV_INDEX_URL= +ENV UV_INDEX_URL=${UV_INDEX_URL} RUN uv sync --locked --all-packages --no-dev ENV PATH="/app/.venv/bin:$PATH" diff --git a/Jenkinsfile b/Jenkinsfile index 9bc6f3b..c9ef232 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -20,6 +20,15 @@ pipeline { string(name: 'UV_INDEX_URL', defaultValue: 'https://mirrors.aliyun.com/pypi/simple', description: 'Optional PyPI mirror index URL for uv sync (empty = uv default)') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: 'Skip the uv sync/pytest stage (faster builds when only packaging)') booleanParam(name: 'PUSH', defaultValue: true, description: 'Push the image to REGISTRY after a successful build') + + // Mirror overrides passed to `docker build` as --build-arg. Defaults target + // CN networks so Jenkins builds don't time out pulling from Docker Hub / + // ghcr.io / npmjs.org / deb.debian.org. Override or blank any of these to + // build against the official upstreams. + string(name: 'NODE_IMAGE', defaultValue: 'registry.jerryyan.net/library/node:20-bookworm-slim', description: 'Stage 1 base image (Docker Hub library/node proxy)') + string(name: 'NPM_REGISTRY', defaultValue: 'https://registry.npmmirror.com', description: 'npm registry URL used by `npm ci`') + string(name: 'UV_IMAGE', defaultValue: 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim', description: 'Stage 2 base image (ghcr.io/astral-sh/uv proxy)') + string(name: 'APT_MIRROR', defaultValue: 'mirrors.aliyun.com', description: 'Debian apt mirror host (e.g. mirrors.aliyun.com). Empty = deb.debian.org') } environment { @@ -79,7 +88,17 @@ pipeline { stage('Build image') { steps { script { - def img = docker.build("${FULL_IMAGE}:${IMAGE_TAG}", '.') + // Pass every mirror override through as --build-arg. Empty values + // are skipped so the Dockerfile ARG default applies. + def buildArgs = [] + ["NODE_IMAGE", "NPM_REGISTRY", "UV_IMAGE", "APT_MIRROR", "UV_INDEX_URL"].each { name -> + def v = params[name]?.toString()?.trim() + if (v) { + buildArgs << "--build-arg ${name}=${v}" + } + } + def dockerArgs = (buildArgs.join(' ') + ' .').trim() + def img = docker.build("${FULL_IMAGE}:${IMAGE_TAG}", dockerArgs) img.tag('latest') env.BUILT_IMAGE = "${FULL_IMAGE}:${IMAGE_TAG}" echo "Built ${env.BUILT_IMAGE} (+ :latest)" diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index e6f75dd..200da83 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -5,11 +5,15 @@ import logging from collections.abc import Callable from contextlib import asynccontextmanager from dataclasses import dataclass +from pathlib import Path from typing import Any from fastapi import FastAPI, status from fastapi import Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.types import Scope from cloud.auth import ( ChainedAuthProvider, @@ -19,6 +23,7 @@ from cloud.auth import ( ) from cloud.config import CloudConfig from cloud.control_config import ( + CloudConfigurationError, CloudControlConfig, load_control_config, validate_control_config, @@ -69,6 +74,25 @@ class CloudApplicationServices: auth_provider: Any +class SpaStaticFiles(StaticFiles): + """``StaticFiles`` variant that falls back to ``index.html`` for SPA routes. + + ``StaticFiles(html=True)`` only serves ``index.html`` for the mount root and + for directory roots; an unknown path like ``/console/tasks/abc`` raises a + plain 404, which breaks deep-link refreshes in a browser-running SPA. This + subclass intercepts 404s for non-asset paths and re-serves ``index.html`` + so the SPA router can take over. + """ + + async def get_response(self, path: str, scope: Scope) -> Any: + try: + return await super().get_response(path, scope) + except StarletteHTTPException as exc: + if exc.status_code == 404 and path != "index.html": + return await super().get_response("index.html", scope) + raise + + def create_app( *, config: CloudControlConfig | None = None, @@ -237,6 +261,24 @@ def create_app( lease_duration_seconds=control_config.lease_duration_seconds, ) ) + + if control_config.console_static_dir: + dist_dir = Path(control_config.console_static_dir) + if not dist_dir.is_dir(): + raise CloudConfigurationError( + f"CLOUD_CONSOLE_STATIC_DIR is not a directory: {dist_dir}" + ) + + @app.get("/", include_in_schema=False) + async def _redirect_to_console() -> RedirectResponse: + return RedirectResponse(url="/console/") + + app.mount( + "/console", + SpaStaticFiles(directory=str(dist_dir), html=True), + name="cloud-console", + ) + return app diff --git a/apps/cloud-api/tests/test_app.py b/apps/cloud-api/tests/test_app.py index 5771ead..0f12760 100644 --- a/apps/cloud-api/tests/test_app.py +++ b/apps/cloud-api/tests/test_app.py @@ -2,6 +2,7 @@ from __future__ import annotations import time from datetime import timedelta +from pathlib import Path import pytest from fastapi.testclient import TestClient @@ -685,3 +686,109 @@ def test_load_control_config_defaults_to_empty_cors_allow_list() -> None: } ) assert config.cors_allowed_origins == () + + +def test_load_control_config_parses_console_static_dir() -> None: + from cloud.control_config import load_control_config + + config = load_control_config( + env={ + "CLOUD_ENVIRONMENT": "local", + "CLOUD_DATABASE_URL": "sqlite:///:memory:", + "CLOUD_CONSOLE_STATIC_DIR": "/app/console-static", + } + ) + assert config.console_static_dir == "/app/console-static" + + +def test_load_control_config_default_console_static_dir_is_none() -> None: + from cloud.control_config import load_control_config + + config = load_control_config( + env={ + "CLOUD_ENVIRONMENT": "local", + "CLOUD_DATABASE_URL": "sqlite:///:memory:", + } + ) + assert config.console_static_dir is None + + +def _write_fake_dist(dist_dir: Path) -> None: + dist_dir.mkdir(parents=True, exist_ok=True) + (dist_dir / "index.html").write_text( + "
cloud console spa", + encoding="utf-8", + ) + assets = dist_dir / "assets" + assets.mkdir(exist_ok=True) + (assets / "index.js").write_text( + "console.log('spa boot');", + encoding="utf-8", + ) + + +def test_console_static_dir_disabled_means_no_console_route() -> None: + app = create_app( + config=CloudControlConfig(database_url="sqlite:///:memory:") + ) + with TestClient(app) as client: + # Without the static dir configured, `/console/` is not registered. + resp = client.get("/console/") + assert resp.status_code == 404 + # And `/` is not redirected either. + root = client.get("/", follow_redirects=False) + assert root.status_code == 404 + + +def test_console_static_dir_mounts_spa_with_fallback_and_redirect(tmp_path) -> None: + dist_dir = tmp_path / "console-static" + _write_fake_dist(dist_dir) + + app = create_app( + config=CloudControlConfig( + database_url="sqlite:///:memory:", + console_static_dir=str(dist_dir), + ) + ) + with TestClient(app) as client: + # `/` redirects to the console mount so operators can visit the host root. + root = client.get("/", follow_redirects=False) + assert root.status_code in {301, 302, 307} + assert root.headers["location"] == "/console/" + + # The mount root serves index.html. + index = client.get("/console/") + assert index.status_code == 200 + assert "cloud console spa" in index.text + + # Real asset files are served at their canonical path. + asset = client.get("/console/assets/index.js") + assert asset.status_code == 200 + assert "spa boot" in asset.text + + # Unknown SPA deep-link paths fall back to index.html so the Vue + # router can take over on a refresh. + deep = client.get("/console/tasks/anything") + assert deep.status_code == 200 + assert "cloud console spa" in deep.text + + # The `/v1/*` API surface is not hijacked by the SPA mount. + api_redirect = client.get("/v1/tasks", follow_redirects=False) + assert api_redirect.status_code == 401 + assert api_redirect.headers["www-authenticate"] == "Bearer" + + # And unknown API paths still return a JSON 404, not the SPA shell. + api_typo = client.get("/v1/this-route-does-not-exist") + assert api_typo.status_code == 404 + assert api_typo.headers["content-type"].startswith("application/json") + + +def test_console_static_dir_rejects_missing_directory(tmp_path) -> None: + missing = tmp_path / "does-not-exist" + with pytest.raises(CloudConfigurationError, match="not a directory"): + create_app( + config=CloudControlConfig( + database_url="sqlite:///:memory:", + console_static_dir=str(missing), + ) + ) diff --git a/cloud-console/vite.config.ts b/cloud-console/vite.config.ts index 6ea8547..18c6b00 100644 --- a/cloud-console/vite.config.ts +++ b/cloud-console/vite.config.ts @@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue"; export default defineConfig({ plugins: [vue()], + base: "/console/", }); diff --git a/compose.deploy.yaml b/compose.deploy.yaml index f2113b8..a8b2e30 100644 --- a/compose.deploy.yaml +++ b/compose.deploy.yaml @@ -32,6 +32,7 @@ services: CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3} + CLOUD_CONSOLE_STATIC_DIR: /app/console-static ports: - "${CLOUD_API_PORT:-8001}:8001" depends_on: diff --git a/compose.yaml b/compose.yaml index 00e77de..29b0ceb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,7 @@ services: CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3} + CLOUD_CONSOLE_STATIC_DIR: /app/console-static ports: - "${CLOUD_API_PORT:-8001}:8001" depends_on: diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index d4ea8af..a119855 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -251,6 +251,30 @@ For a production build, run `npm run build` and serve the resulting `dist/` behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`. +### Same-origin deployment (baked into the Cloud API image) + +The Jenkins-built Docker image already carries the SPA at `/app/console-static`, +and `compose.yaml` / `compose.deploy.yaml` set +`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In +this mode the Cloud API itself serves the console at `/console/` (visiting `/` +307-redirects there), so operators can open `https://