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