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:
2026-07-13 14:40:52 +08:00
co-authored by Claude Opus 4.6
parent d673e77171
commit 2ccbc63d95
10 changed files with 257 additions and 4 deletions
+2
View File
@@ -8,3 +8,5 @@ __pycache__
*.sqlite3
tasks
console/node_modules
cloud-console/node_modules
cloud-console/dist
+47 -2
View File
@@ -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
View File
@@ -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)"
+43 -1
View File
@@ -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
+107
View File
@@ -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),
)
)
+1
View File
@@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "/console/",
});
+1
View File
@@ -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:
+1
View File
@@ -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:
+24
View File
@@ -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