From 321abe064603a2553aba399c4fffceb95eecb409 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 6 Apr 2026 18:39:07 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20test/demo/cloud=20profiles=20=E2=80=94?= =?UTF-8?q?=20middleware,=20compose=20files,=20nginx,=20manage.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/config.py: centralized Settings (DEMO_MODE, CLOUD_MODE, ports, etc.) app/middleware/demo.py: DemoModeMiddleware — caps sessions (429), blocks export (403), adds X-Linnet-Mode header app/middleware/cloud.py: CloudAuthMiddleware — requires X-CF-Session on /session/* routes, 401 without it app/services/session_store.py: active_session_count() for demo cap app/main.py: wires middleware conditionally, extends CORS for cloud origins compose.test.yml: hermetic pytest runner in Docker (CF_VOICE_MOCK=1) compose.demo.yml: DEMO_MODE=true, ports 8523/8524, demo.circuitforge.tech/linnet compose.cloud.yml: CLOUD_MODE=true, ports 8522/8527, menagerie.circuitforge.tech/linnet docker/web/Dockerfile: two-stage build (node:20 → nginx:alpine), VITE_BASE_URL/VITE_API_BASE ARGs docker/web/nginx.conf: SSE + WS proxy, SPA routing (dev/demo) docker/web/nginx.cloud.conf: adds X-CF-Session forwarding, /linnet/ alias for path-strip Caddy routing manage.sh: profile arg (dev|demo|cloud|test), start/stop/restart/status/test/logs/build/open per profile tests/test_profiles.py: 8 tests — demo export block, session cap, cloud auth gate, mode headers --- app/config.py | 29 ++++++ app/main.py | 35 ++++++-- app/middleware/__init__.py | 0 app/middleware/cloud.py | 41 +++++++++ app/middleware/demo.py | 45 ++++++++++ app/services/session_store.py | 5 ++ compose.cloud.yml | 56 ++++++++++++ compose.demo.yml | 51 +++++++++++ compose.test.yml | 23 +++++ docker/web/Dockerfile | 22 +++++ docker/web/nginx.cloud.conf | 51 +++++++++++ docker/web/nginx.conf | 43 +++++++++ manage.sh | 162 +++++++++++++++++++++++++++------- tests/test_profiles.py | 114 ++++++++++++++++++++++++ 14 files changed, 636 insertions(+), 41 deletions(-) create mode 100644 app/config.py create mode 100644 app/middleware/__init__.py create mode 100644 app/middleware/cloud.py create mode 100644 app/middleware/demo.py create mode 100644 compose.cloud.yml create mode 100644 compose.demo.yml create mode 100644 compose.test.yml create mode 100644 docker/web/Dockerfile create mode 100644 docker/web/nginx.cloud.conf create mode 100644 docker/web/nginx.conf create mode 100644 tests/test_profiles.py diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..41d072b --- /dev/null +++ b/app/config.py @@ -0,0 +1,29 @@ +# app/config.py — runtime mode settings +from __future__ import annotations + +import os + + +class Settings: + """Read-once settings from environment variables.""" + + demo_mode: bool = os.getenv("DEMO_MODE", "").lower() in ("1", "true", "yes") + cloud_mode: bool = os.getenv("CLOUD_MODE", "").lower() in ("1", "true", "yes") + + # DEMO: max simultaneous active sessions (prevents resource abuse on the demo server) + demo_max_sessions: int = int(os.getenv("DEMO_MAX_SESSIONS", "3")) + # DEMO: auto-kill sessions after this many seconds of inactivity + demo_session_ttl_s: int = int(os.getenv("DEMO_SESSION_TTL_S", "300")) # 5 min + + # CLOUD: where Caddy injects the cf_session user token + cloud_session_header: str = os.getenv("CLOUD_SESSION_HEADER", "X-CF-Session") + cloud_data_root: str = os.getenv("CLOUD_DATA_ROOT", "/devl/linnet-cloud-data") + + heimdall_url: str = os.getenv("HEIMDALL_URL", "https://license.circuitforge.tech") + + linnet_port: int = int(os.getenv("LINNET_PORT", "8522")) + linnet_frontend_port: int = int(os.getenv("LINNET_FRONTEND_PORT", "8521")) + linnet_base_url: str = os.getenv("LINNET_BASE_URL", "") + + +settings = Settings() diff --git a/app/main.py b/app/main.py index eb9acfc..6dd5748 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api import audio, events, export, history, sessions +from app.config import settings logging.basicConfig( level=logging.INFO, @@ -20,19 +21,38 @@ app = FastAPI( version="0.1.0", ) -# CORS: allow localhost frontend dev server and same-origin in production -_frontend_port = os.getenv("LINNET_FRONTEND_PORT", "8521") +# ── Mode middleware (applied before CORS so headers are always present) ────── +if settings.demo_mode: + from app.middleware.demo import DemoModeMiddleware + app.add_middleware(DemoModeMiddleware) + logging.getLogger(__name__).info("DEMO_MODE active") + +if settings.cloud_mode: + from app.middleware.cloud import CloudAuthMiddleware + app.add_middleware(CloudAuthMiddleware) + logging.getLogger(__name__).info("CLOUD_MODE active") + +# ── CORS ───────────────────────────────────────────────────────────────────── +_frontend_port = str(settings.linnet_frontend_port) +_origins = [ + f"http://localhost:{_frontend_port}", + f"http://127.0.0.1:{_frontend_port}", +] +if settings.cloud_mode: + _origins += [ + "https://menagerie.circuitforge.tech", + "https://circuitforge.tech", + ] + app.add_middleware( CORSMiddleware, - allow_origins=[ - f"http://localhost:{_frontend_port}", - "http://127.0.0.1:" + _frontend_port, - ], + allow_origins=_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +# ── Routers ─────────────────────────────────────────────────────────────────── app.include_router(sessions.router) app.include_router(events.router) app.include_router(history.router) @@ -42,4 +62,5 @@ app.include_router(export.router) @app.get("/health") def health() -> dict: - return {"status": "ok", "service": "linnet"} + mode = "demo" if settings.demo_mode else ("cloud" if settings.cloud_mode else "dev") + return {"status": "ok", "service": "linnet", "mode": mode} diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/middleware/cloud.py b/app/middleware/cloud.py new file mode 100644 index 0000000..6903848 --- /dev/null +++ b/app/middleware/cloud.py @@ -0,0 +1,41 @@ +# app/middleware/cloud.py — CLOUD_MODE auth +# +# When CLOUD_MODE=true, all /session/* routes require the X-CF-Session header +# (injected by Caddy from the cf_session cookie set by the website auth flow). +# The header value is forwarded opaquely — Linnet trusts it as an opaque user ID. +# Full Heimdall JWT validation is a v1.0 addition (tracked in linnet#16). +from __future__ import annotations + +import logging + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.config import settings + +logger = logging.getLogger(__name__) + +# Paths that don't require auth even in cloud mode +_PUBLIC_PATHS = {"/health", "/docs", "/openapi.json", "/redoc"} + + +class CloudAuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + path = request.url.path + + if path in _PUBLIC_PATHS or not path.startswith("/session"): + return await call_next(request) + + session_token = request.headers.get(settings.cloud_session_header, "").strip() + if not session_token: + return JSONResponse( + status_code=401, + content={"detail": "Authentication required. Sign in at circuitforge.tech."}, + ) + + # Attach the user identity to request state so endpoints can use it + request.state.cf_user = session_token + response = await call_next(request) + response.headers["X-Linnet-Mode"] = "cloud" + return response diff --git a/app/middleware/demo.py b/app/middleware/demo.py new file mode 100644 index 0000000..5b1a533 --- /dev/null +++ b/app/middleware/demo.py @@ -0,0 +1,45 @@ +# app/middleware/demo.py — DEMO_MODE restrictions +# +# When DEMO_MODE=true: +# - Session creation is capped at DEMO_MAX_SESSIONS concurrent sessions +# - Export endpoint returns 403 (no personal data leaves the demo server) +# - Response header X-Linnet-Mode: demo on all responses +# - CF_VOICE_MOCK is forced on (see compose.demo.yml) +from __future__ import annotations + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.config import settings +from app.services import session_store + + +class DemoModeMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + path = request.url.path + + # Block export in demo mode — no data leaves the demo server + if path.endswith("/export"): + return JSONResponse( + status_code=403, + content={"detail": "Export is disabled in demo mode."}, + ) + + # Cap concurrent session creation + if path == "/session/start" and request.method == "POST": + active = session_store.active_session_count() + if active >= settings.demo_max_sessions: + return JSONResponse( + status_code=429, + content={ + "detail": ( + f"Demo server is at capacity ({settings.demo_max_sessions} " + "active sessions). Please try again in a moment." + ) + }, + ) + + response = await call_next(request) + response.headers["X-Linnet-Mode"] = "demo" + return response diff --git a/app/services/session_store.py b/app/services/session_store.py index c492fb5..34b0057 100644 --- a/app/services/session_store.py +++ b/app/services/session_store.py @@ -35,6 +35,11 @@ def get_session(session_id: str) -> Session | None: return _sessions.get(session_id) +def active_session_count() -> int: + """Return the number of currently running sessions.""" + return sum(1 for s in _sessions.values() if s.state == "running") + + def end_session(session_id: str) -> bool: """Stop and remove a session. Returns True if it existed.""" session = _sessions.pop(session_id, None) diff --git a/compose.cloud.yml b/compose.cloud.yml new file mode 100644 index 0000000..5e4194a --- /dev/null +++ b/compose.cloud.yml @@ -0,0 +1,56 @@ +# compose.cloud.yml — Cloud managed instance +# +# Project: linnet-cloud (docker compose -f compose.cloud.yml -p linnet-cloud ...) +# Web: http://127.0.0.1:8527 → menagerie.circuitforge.tech/linnet (via Caddy + JWT) +# API: internal only on linnet-cloud-net (nginx proxies /session/ → linnet-api:8522) +# +# Requires in .env: +# CLOUD_MODE=true +# LINNET_LICENSE_KEY=CFG-LNNT-... (or per-request key via API) +# HEIMDALL_URL=https://license.circuitforge.tech +# DIRECTUS_JWT_SECRET=... (Caddy injects X-CF-Session from cf_session cookie) +# HF_TOKEN=... (needed for real cf-voice inference; omit for mock) +# CF_VOICE_MOCK=0 (set to 1 during staged rollout) + +services: + linnet-api: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + env_file: .env + environment: + CLOUD_MODE: "true" + LINNET_PORT: "8522" + LINNET_FRONTEND_PORT: "8527" + LINNET_BASE_URL: "https://menagerie.circuitforge.tech/linnet" + CLOUD_DATA_ROOT: /devl/linnet-cloud-data + CF_ORCH_URL: http://host.docker.internal:7700 + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - /devl/linnet-cloud-data:/devl/linnet-cloud-data + - ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro + networks: + - linnet-cloud-net + + linnet-web: + build: + context: . + dockerfile: docker/web/Dockerfile + args: + VITE_BASE_URL: /linnet/ + VITE_API_BASE: /linnet + restart: unless-stopped + ports: + - "8527:80" + volumes: + - ./docker/web/nginx.cloud.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - linnet-cloud-net + depends_on: + - linnet-api + +networks: + linnet-cloud-net: + driver: bridge diff --git a/compose.demo.yml b/compose.demo.yml new file mode 100644 index 0000000..5de1957 --- /dev/null +++ b/compose.demo.yml @@ -0,0 +1,51 @@ +# compose.demo.yml — Public demo instance +# +# Runs a capped, mock-only Linnet session for demo.circuitforge.tech/linnet. +# - DEMO_MODE=true: caps concurrent sessions, disables export +# - CF_VOICE_MOCK=1: synthetic ToneEvent stream (no inference, no HF_TOKEN needed) +# - Port 8523 (API internal), 8524 (frontend via nginx) +# +# Usage: +# docker compose -f compose.demo.yml -p linnet-demo up -d +# docker compose -f compose.demo.yml -p linnet-demo down +# +# Caddy: demo.circuitforge.tech/linnet* → host port 8524 + +services: + linnet-api: + build: + context: . + dockerfile: Dockerfile + environment: + DEMO_MODE: "true" + CF_VOICE_MOCK: "1" + LINNET_PORT: "8523" + LINNET_FRONTEND_PORT: "8524" + LINNET_BASE_URL: "/linnet" + DEMO_MAX_SESSIONS: "3" + DEMO_SESSION_TTL_S: "300" + networks: + - linnet-demo-net + restart: unless-stopped + # API is internal-only — frontend nginx proxies to it + + linnet-web: + build: + context: . + dockerfile: docker/web/Dockerfile + args: + VITE_BASE_URL: /linnet/ + VITE_API_BASE: /linnet + ports: + - "8524:80" + volumes: + - ./docker/web/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - linnet-demo-net + depends_on: + - linnet-api + restart: unless-stopped + +networks: + linnet-demo-net: + driver: bridge diff --git a/compose.test.yml b/compose.test.yml new file mode 100644 index 0000000..330f6f5 --- /dev/null +++ b/compose.test.yml @@ -0,0 +1,23 @@ +# compose.test.yml — CI / automated test runner +# +# Builds the API image and runs the pytest suite inside the container. +# No frontend. No inference (CF_VOICE_MOCK=1 always). +# +# Usage: +# docker compose -f compose.test.yml run --rm linnet-test +# docker compose -f compose.test.yml run --rm linnet-test -- tests/test_tiers.py + +services: + linnet-test: + build: + context: . + dockerfile: Dockerfile + command: > + sh -c "pip install -e '.[dev]' -q && + python -m pytest tests/ -v --tb=short ${@}" + environment: + CF_VOICE_MOCK: "1" + DEMO_MODE: "" + CLOUD_MODE: "" + PYTHONUNBUFFERED: "1" + # No ports, no volumes — hermetic diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile new file mode 100644 index 0000000..986ed15 --- /dev/null +++ b/docker/web/Dockerfile @@ -0,0 +1,22 @@ +# Stage 1: build Vue 3 frontend +FROM node:20-alpine AS build +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm ci --prefer-offline +COPY frontend/ ./ + +# Vite bakes these as static strings into the bundle. +# VITE_BASE_URL: path prefix the app is served under (/ for dev, /linnet for cloud) +# VITE_API_BASE: prefix for all /session/* fetch calls (empty for direct, /linnet for cloud) +ARG VITE_BASE_URL=/ +ARG VITE_API_BASE= +ENV VITE_BASE_URL=$VITE_BASE_URL +ENV VITE_API_BASE=$VITE_API_BASE + +RUN npm run build + +# Stage 2: serve via nginx +FROM nginx:alpine +COPY docker/web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/docker/web/nginx.cloud.conf b/docker/web/nginx.cloud.conf new file mode 100644 index 0000000..bbdb954 --- /dev/null +++ b/docker/web/nginx.cloud.conf @@ -0,0 +1,51 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Proxy to FastAPI container on the cloud network + location /session/ { + proxy_pass http://linnet-api:8522; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + # Forward the session header injected by Caddy from cf_session cookie + proxy_set_header X-CF-Session $http_x_cf_session; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_buffering off; + proxy_read_timeout 3600s; + } + + location /health { + proxy_pass http://linnet-api:8522; + proxy_set_header Host $host; + } + + # When accessed via Caddy at /linnet (path-strip), assets are at /linnet/assets/... + # but stored at /assets/... in nginx's root. Alias so direct port access still works. + location ^~ /linnet/ { + alias /usr/share/nginx/html/; + try_files $uri $uri/ /index.html; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri /index.html; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf new file mode 100644 index 0000000..34f967d --- /dev/null +++ b/docker/web/nginx.conf @@ -0,0 +1,43 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Proxy API + WebSocket requests to the FastAPI container + location /session/ { + proxy_pass http://linnet-api:8522; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # WebSocket upgrade for the audio WS endpoint + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # SSE: disable buffering so events reach the client immediately + proxy_buffering off; + proxy_read_timeout 3600s; + } + + location /health { + proxy_pass http://linnet-api:8522; + proxy_set_header Host $host; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri /index.html; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/manage.sh b/manage.sh index 64e0cc6..cd3beda 100755 --- a/manage.sh +++ b/manage.sh @@ -1,72 +1,166 @@ #!/usr/bin/env bash -# manage.sh — Linnet dev management script +# manage.sh — Linnet dev/demo/cloud management script set -euo pipefail CMD=${1:-help} +PROFILE=${2:-dev} # dev | demo | cloud API_PORT=${LINNET_PORT:-8522} FE_PORT=${LINNET_FRONTEND_PORT:-8521} +# ── Helpers ─────────────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${GREEN}[linnet]${NC} $*"; } +warn() { echo -e "${YELLOW}[linnet]${NC} $*"; } +error() { echo -e "${RED}[linnet]${NC} $*" >&2; exit 1; } + _check_env() { if [[ ! -f .env ]]; then - echo "No .env found — copying .env.example" + warn "No .env found — copying .env.example" cp .env.example .env fi } +_compose_cmd() { + # Returns the correct compose file flags for a given profile + case "$1" in + demo) echo "-f compose.demo.yml -p linnet-demo" ;; + cloud) echo "-f compose.cloud.yml -p linnet-cloud" ;; + test) echo "-f compose.test.yml" ;; + dev) echo "-f compose.yml" ;; + *) error "Unknown profile: $1. Use dev|demo|cloud|test." ;; + esac +} + +# ── Commands ────────────────────────────────────────────────────────────────── case "$CMD" in + start) _check_env - echo "Starting Linnet API on :${API_PORT} (mock mode)…" - CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \ - --host 0.0.0.0 --port "$API_PORT" --reload & - echo "Starting Linnet frontend on :${FE_PORT}…" - cd frontend && npm install --silent && npm run dev & - echo "API: http://localhost:${API_PORT}" - echo "UI: http://localhost:${FE_PORT}" - wait + if [[ "$PROFILE" == "dev" ]]; then + info "Starting Linnet API on :${API_PORT} (mock mode, dev)…" + CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \ + --host 0.0.0.0 --port "$API_PORT" --reload & + info "Starting Linnet frontend on :${FE_PORT}…" + cd frontend && npm install --silent && npm run dev & + info "API: http://localhost:${API_PORT}" + info "UI: http://localhost:${FE_PORT}" + wait + else + FLAGS=$(_compose_cmd "$PROFILE") + info "Starting Linnet ($PROFILE profile)…" + # shellcheck disable=SC2086 + docker compose $FLAGS up -d --build + case "$PROFILE" in + demo) info "Frontend: http://localhost:8524" ;; + cloud) info "Frontend: http://localhost:8527 → menagerie.circuitforge.tech/linnet" ;; + esac + fi ;; stop) - echo "Stopping Linnet processes…" - pkill -f "uvicorn app.main:app" 2>/dev/null || true - pkill -f "vite.*${FE_PORT}" 2>/dev/null || true - echo "Stopped." + if [[ "$PROFILE" == "dev" ]]; then + info "Stopping Linnet dev processes…" + pkill -f "uvicorn app.main:app" 2>/dev/null || true + pkill -f "vite" 2>/dev/null || true + else + FLAGS=$(_compose_cmd "$PROFILE") + # shellcheck disable=SC2086 + docker compose $FLAGS down + fi + info "Stopped ($PROFILE)." + ;; + + restart) + "$0" stop "$PROFILE" + "$0" start "$PROFILE" ;; status) - echo -n "API: " - curl -sf "http://localhost:${API_PORT}/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running" - echo -n "Frontend: " - curl -sf "http://localhost:${FE_PORT}" -o /dev/null && echo "running" || echo "not running" + case "$PROFILE" in + dev) + echo -n "API: " + curl -sf "http://localhost:${API_PORT}/health" 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'], '|', d.get('mode','dev'))" \ + || echo "not running" + echo -n "Frontend: " + curl -sf "http://localhost:${FE_PORT}" -o /dev/null && echo "running" || echo "not running" + ;; + demo) + echo -n "Demo API: "; curl -sf "http://localhost:8523/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running" + echo -n "Demo Web: "; curl -sf "http://localhost:8524" -o /dev/null && echo "running" || echo "not running" + ;; + cloud) + echo -n "Cloud API: "; curl -sf "http://localhost:8522/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running" + echo -n "Cloud Web: "; curl -sf "http://localhost:8527" -o /dev/null && echo "running" || echo "not running" + ;; + esac ;; test) _check_env - CF_VOICE_MOCK=1 conda run -n cf python -m pytest tests/ -v "${@:2}" + if [[ "$PROFILE" == "dev" ]]; then + info "Running test suite (local)…" + CF_VOICE_MOCK=1 conda run -n cf python -m pytest tests/ -v "${@:3}" + else + info "Running test suite (Docker)…" + docker compose -f compose.test.yml run --rm linnet-test "${@:3}" + fi ;; logs) - echo "Use 'docker compose logs -f' in Docker mode, or check terminal for dev mode." + if [[ "$PROFILE" == "dev" ]]; then + warn "Dev mode: check terminal output." + else + FLAGS=$(_compose_cmd "$PROFILE") + SERVICE=${3:-} + # shellcheck disable=SC2086 + docker compose $FLAGS logs -f $SERVICE + fi ;; - docker-start) - _check_env - docker compose up -d - echo "API: http://localhost:${API_PORT}" - echo "UI: http://localhost:${FE_PORT}" - ;; - - docker-stop) - docker compose down + build) + info "Building Docker images ($PROFILE)…" + FLAGS=$(_compose_cmd "$PROFILE") + # shellcheck disable=SC2086 + docker compose $FLAGS build + info "Build complete." ;; open) - xdg-open "http://localhost:${FE_PORT}" 2>/dev/null \ - || open "http://localhost:${FE_PORT}" 2>/dev/null \ - || echo "Open http://localhost:${FE_PORT} in your browser" + case "$PROFILE" in + demo) URL="http://localhost:8524" ;; + cloud) URL="http://localhost:8527" ;; + *) URL="http://localhost:${FE_PORT}" ;; + esac + xdg-open "$URL" 2>/dev/null || open "$URL" 2>/dev/null || info "Open $URL in your browser" ;; - *) - echo "Usage: $0 {start|stop|status|test|logs|docker-start|docker-stop|open}" + help|*) + echo "" + echo " Usage: $0 [profile]" + echo "" + echo " Commands:" + echo -e " ${GREEN}start${NC} [profile] Start API + frontend" + echo -e " ${GREEN}stop${NC} [profile] Stop services" + echo -e " ${GREEN}restart${NC} [profile] Stop then start" + echo -e " ${GREEN}status${NC} [profile] Check running services" + echo -e " ${GREEN}test${NC} [profile] Run pytest suite" + echo -e " ${GREEN}logs${NC} [profile] Tail logs (Docker profiles only)" + echo -e " ${GREEN}build${NC} [profile] Build Docker images" + echo -e " ${GREEN}open${NC} [profile] Open UI in browser" + echo "" + echo " Profiles:" + echo " dev (default) Local uvicorn + Vite dev server, mock mode" + echo " demo Docker: DEMO_MODE=true, port 8524" + echo " cloud Docker: CLOUD_MODE=true, port 8527 → menagerie.circuitforge.tech/linnet" + echo " test Docker: pytest suite, hermetic" + echo "" + echo " Examples:" + echo " $0 start # dev mode" + echo " $0 start demo # demo stack" + echo " $0 test # local pytest" + echo " $0 test docker # pytest in Docker" + echo " $0 logs demo # demo logs" + echo "" ;; esac diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..4a1f02d --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,114 @@ +# tests/test_profiles.py — DEMO_MODE and CLOUD_MODE middleware behaviour +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def demo_client(): + """TestClient with DEMO_MODE active.""" + os.environ["DEMO_MODE"] = "true" + os.environ["DEMO_MAX_SESSIONS"] = "2" + # Re-import to pick up new env + import importlib + import app.config as cfg + importlib.reload(cfg) + import app.main as main_mod + importlib.reload(main_mod) + with TestClient(main_mod.app) as c: + yield c + os.environ.pop("DEMO_MODE", None) + os.environ.pop("DEMO_MAX_SESSIONS", None) + importlib.reload(cfg) + importlib.reload(main_mod) + + +@pytest.fixture() +def cloud_client(): + """TestClient with CLOUD_MODE active.""" + os.environ["CLOUD_MODE"] = "true" + import importlib + import app.config as cfg + importlib.reload(cfg) + import app.main as main_mod + importlib.reload(main_mod) + with TestClient(main_mod.app) as c: + yield c + os.environ.pop("CLOUD_MODE", None) + importlib.reload(cfg) + importlib.reload(main_mod) + + +# ── Demo mode ───────────────────────────────────────────────────────────────── + +def test_demo_health_mode(demo_client): + resp = demo_client.get("/health") + assert resp.json()["mode"] == "demo" + + +def test_demo_export_blocked(demo_client): + """Export must return 403 in demo mode.""" + # Start a session first so the export route can match + start = demo_client.post("/session/start") + assert start.status_code == 200 + sid = start.json()["session_id"] + resp = demo_client.get(f"/session/{sid}/export") + assert resp.status_code == 403 + + +def test_demo_header_present(demo_client): + resp = demo_client.get("/health") + assert resp.headers.get("x-linnet-mode") == "demo" + + +def test_demo_session_cap(demo_client): + """Creating sessions beyond DEMO_MAX_SESSIONS returns 429.""" + # Create up to the cap + sessions = [] + for _ in range(2): + r = demo_client.post("/session/start") + assert r.status_code == 200 + sessions.append(r.json()["session_id"]) + + # One more should be rejected + overflow = demo_client.post("/session/start") + assert overflow.status_code == 429 + + # Clean up + for sid in sessions: + demo_client.delete(f"/session/{sid}/end") + + +# ── Cloud mode ──────────────────────────────────────────────────────────────── + +def test_cloud_health_no_auth(cloud_client): + """Health endpoint should not require auth.""" + resp = cloud_client.get("/health") + assert resp.status_code == 200 + assert resp.json()["mode"] == "cloud" + + +def test_cloud_session_requires_auth(cloud_client): + """Session start without X-CF-Session should be 401.""" + resp = cloud_client.post("/session/start") + assert resp.status_code == 401 + + +def test_cloud_session_with_auth(cloud_client): + """Valid X-CF-Session header should pass through.""" + resp = cloud_client.post( + "/session/start", + headers={"X-CF-Session": "test-user-token"}, + ) + assert resp.status_code == 200 + + +def test_cloud_header_present(cloud_client): + resp = cloud_client.get( + "/session/ghost", + headers={"X-CF-Session": "user"}, + ) + assert resp.headers.get("x-linnet-mode") == "cloud"