feat(cloud-api): bake cloud-console SPA into the image and serve at /console
Multi-stage Dockerfile: stage 1 (node:20-bookworm-slim) builds cloud-console with vite base "/console/"; stage 2 (uv) copies dist/ to /app/console-static. Cloud API mounts the SPA at /console via SpaStaticFiles (StaticFiles subclass that falls back to index.html for deep-link refreshes) when the new CLOUD_CONSOLE_STATIC_DIR env is set, and 307-redirects / to /console/. Static files bypass bearer auth (the SPA shell is public; tokens are still required for /v1/*). Compose enables the mount by default; local dev still uses npm run dev + CLOUD_CONSOLE_CORS_ORIGINS. Jenkinsfile passes mirror overrides (NODE_IMAGE, NPM_REGISTRY, UV_IMAGE, APT_MIRROR, UV_INDEX_URL) as --build-arg, defaulting to CN mirrors (registry.jerryyan.net, registry.npmmirror.com, registry-ghcr.jerryyan.top, mirrors.aliyun.com) so CN builds don't time out; Dockerfile ARGs default to official upstreams so `docker build .` still works anywhere. Backend suite: 443 passed (-m "not integration"); cloud-console typecheck and production build succeed with the new base path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,3 +8,5 @@ __pycache__
|
||||
*.sqlite3
|
||||
tasks
|
||||
console/node_modules
|
||||
cloud-console/node_modules
|
||||
cloud-console/dist
|
||||
|
||||
+47
-2
@@ -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"
|
||||
|
||||
Vendored
+20
-1
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
"<!doctype html><html><body>cloud console spa</body></html>",
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: "/console/",
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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://<cloud-api-host>:8001/`
|
||||
directly — no separate dev server, no static host, no CORS allow-list needed
|
||||
(the SPA and the API share one origin).
|
||||
|
||||
The SPA shell (`index.html`, JS, CSS) is served without a bearer token by
|
||||
design — `_authorize(...)` is called inside the `/v1/*` route handlers, not in
|
||||
middleware, so the SPA can boot before the operator pastes a token. All
|
||||
`/v1/*` API calls still require `tasks:read`/`pool:read`/`plugins:read` scopes
|
||||
as before.
|
||||
|
||||
To opt out (e.g. for local development where you run `npm run dev`), leave
|
||||
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
|
||||
|
||||
Jenkins build args (`NODE_IMAGE`, `NPM_REGISTRY`, `UV_IMAGE`, `APT_MIRROR`,
|
||||
`UV_INDEX_URL`) default to CN mirrors so builds don't time out pulling from
|
||||
Docker Hub / ghcr.io / npmjs.org / deb.debian.org. Blank any of them to fall
|
||||
back to the upstream.
|
||||
|
||||
## Runtime AI Planner
|
||||
|
||||
The Host Agent reuses the local Runtime planner. AI planning is disabled by
|
||||
|
||||
@@ -33,6 +33,7 @@ class CloudControlConfig:
|
||||
credentials: tuple[BearerCredential, ...] = ()
|
||||
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
|
||||
cors_allowed_origins: tuple[str, ...] = ()
|
||||
console_static_dir: str | None = None
|
||||
|
||||
|
||||
def load_control_config(
|
||||
@@ -94,6 +95,9 @@ def load_control_config(
|
||||
cors_allowed_origins=_parse_cors_origins(
|
||||
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
|
||||
),
|
||||
console_static_dir=_parse_optional_string(
|
||||
values.get("CLOUD_CONSOLE_STATIC_DIR")
|
||||
),
|
||||
)
|
||||
validate_control_config(config)
|
||||
return config
|
||||
@@ -229,3 +233,10 @@ def _parse_cors_origins(raw_value: str | None) -> tuple[str, ...]:
|
||||
if raw_value is None or not raw_value.strip():
|
||||
return ()
|
||||
return tuple(origin.strip() for origin in raw_value.split(",") if origin.strip())
|
||||
|
||||
|
||||
def _parse_optional_string(raw_value: str | None) -> str | None:
|
||||
if raw_value is None:
|
||||
return None
|
||||
stripped = raw_value.strip()
|
||||
return stripped or None
|
||||
|
||||
Reference in New Issue
Block a user