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
|
*.sqlite3
|
||||||
tasks
|
tasks
|
||||||
console/node_modules
|
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 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -7,10 +36,26 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=frontend /app/dist /app/console-static
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG UV_INDEX_URL=
|
||||||
|
ENV UV_INDEX_URL=${UV_INDEX_URL}
|
||||||
RUN uv sync --locked --all-packages --no-dev
|
RUN uv sync --locked --all-packages --no-dev
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
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)')
|
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: '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')
|
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 {
|
environment {
|
||||||
@@ -79,7 +88,17 @@ pipeline {
|
|||||||
stage('Build image') {
|
stage('Build image') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
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')
|
img.tag('latest')
|
||||||
env.BUILT_IMAGE = "${FULL_IMAGE}:${IMAGE_TAG}"
|
env.BUILT_IMAGE = "${FULL_IMAGE}:${IMAGE_TAG}"
|
||||||
echo "Built ${env.BUILT_IMAGE} (+ :latest)"
|
echo "Built ${env.BUILT_IMAGE} (+ :latest)"
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import logging
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, status
|
||||||
from fastapi import Request
|
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 (
|
from cloud.auth import (
|
||||||
ChainedAuthProvider,
|
ChainedAuthProvider,
|
||||||
@@ -19,6 +23,7 @@ from cloud.auth import (
|
|||||||
)
|
)
|
||||||
from cloud.config import CloudConfig
|
from cloud.config import CloudConfig
|
||||||
from cloud.control_config import (
|
from cloud.control_config import (
|
||||||
|
CloudConfigurationError,
|
||||||
CloudControlConfig,
|
CloudControlConfig,
|
||||||
load_control_config,
|
load_control_config,
|
||||||
validate_control_config,
|
validate_control_config,
|
||||||
@@ -69,6 +74,25 @@ class CloudApplicationServices:
|
|||||||
auth_provider: Any
|
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(
|
def create_app(
|
||||||
*,
|
*,
|
||||||
config: CloudControlConfig | None = None,
|
config: CloudControlConfig | None = None,
|
||||||
@@ -237,6 +261,24 @@ def create_app(
|
|||||||
lease_duration_seconds=control_config.lease_duration_seconds,
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
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 == ()
|
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({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
base: "/console/",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
|
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
|
||||||
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
|
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
|
||||||
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
|
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
|
||||||
|
CLOUD_CONSOLE_STATIC_DIR: /app/console-static
|
||||||
ports:
|
ports:
|
||||||
- "${CLOUD_API_PORT:-8001}:8001"
|
- "${CLOUD_API_PORT:-8001}:8001"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
|
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
|
||||||
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
|
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
|
||||||
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
|
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
|
||||||
|
CLOUD_CONSOLE_STATIC_DIR: /app/console-static
|
||||||
ports:
|
ports:
|
||||||
- "${CLOUD_API_PORT:-8001}:8001"
|
- "${CLOUD_API_PORT:-8001}:8001"
|
||||||
depends_on:
|
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
|
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`.
|
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
|
## Runtime AI Planner
|
||||||
|
|
||||||
The Host Agent reuses the local Runtime planner. AI planning is disabled by
|
The Host Agent reuses the local Runtime planner. AI planning is disabled by
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class CloudControlConfig:
|
|||||||
credentials: tuple[BearerCredential, ...] = ()
|
credentials: tuple[BearerCredential, ...] = ()
|
||||||
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
|
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
|
||||||
cors_allowed_origins: tuple[str, ...] = ()
|
cors_allowed_origins: tuple[str, ...] = ()
|
||||||
|
console_static_dir: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_control_config(
|
def load_control_config(
|
||||||
@@ -94,6 +95,9 @@ def load_control_config(
|
|||||||
cors_allowed_origins=_parse_cors_origins(
|
cors_allowed_origins=_parse_cors_origins(
|
||||||
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
|
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
|
||||||
),
|
),
|
||||||
|
console_static_dir=_parse_optional_string(
|
||||||
|
values.get("CLOUD_CONSOLE_STATIC_DIR")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
validate_control_config(config)
|
validate_control_config(config)
|
||||||
return 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():
|
if raw_value is None or not raw_value.strip():
|
||||||
return ()
|
return ()
|
||||||
return tuple(origin.strip() for origin in raw_value.split(",") if origin.strip())
|
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