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:
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user