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
+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),
)
)