feat: test/demo/cloud profiles — middleware, compose files, nginx, manage.sh
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
This commit is contained in:
parent
7e14f9135e
commit
321abe0646
14 changed files with 636 additions and 41 deletions
29
app/config.py
Normal file
29
app/config.py
Normal file
|
|
@ -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()
|
||||
35
app/main.py
35
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}
|
||||
|
|
|
|||
0
app/middleware/__init__.py
Normal file
0
app/middleware/__init__.py
Normal file
41
app/middleware/cloud.py
Normal file
41
app/middleware/cloud.py
Normal file
|
|
@ -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
|
||||
45
app/middleware/demo.py
Normal file
45
app/middleware/demo.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
56
compose.cloud.yml
Normal file
56
compose.cloud.yml
Normal file
|
|
@ -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
|
||||
51
compose.demo.yml
Normal file
51
compose.demo.yml
Normal file
|
|
@ -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
|
||||
23
compose.test.yml
Normal file
23
compose.test.yml
Normal file
|
|
@ -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
|
||||
22
docker/web/Dockerfile
Normal file
22
docker/web/Dockerfile
Normal file
|
|
@ -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
|
||||
51
docker/web/nginx.cloud.conf
Normal file
51
docker/web/nginx.cloud.conf
Normal file
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
43
docker/web/nginx.conf
Normal file
43
docker/web/nginx.conf
Normal file
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
162
manage.sh
162
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 <command> [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
|
||||
|
|
|
|||
114
tests/test_profiles.py
Normal file
114
tests/test_profiles.py
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue