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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api import audio, events, export, history, sessions
|
from app.api import audio, events, export, history, sessions
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -20,19 +21,38 @@ app = FastAPI(
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS: allow localhost frontend dev server and same-origin in production
|
# ── Mode middleware (applied before CORS so headers are always present) ──────
|
||||||
_frontend_port = os.getenv("LINNET_FRONTEND_PORT", "8521")
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[
|
allow_origins=_origins,
|
||||||
f"http://localhost:{_frontend_port}",
|
|
||||||
"http://127.0.0.1:" + _frontend_port,
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||||
app.include_router(sessions.router)
|
app.include_router(sessions.router)
|
||||||
app.include_router(events.router)
|
app.include_router(events.router)
|
||||||
app.include_router(history.router)
|
app.include_router(history.router)
|
||||||
|
|
@ -42,4 +62,5 @@ app.include_router(export.router)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
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)
|
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:
|
def end_session(session_id: str) -> bool:
|
||||||
"""Stop and remove a session. Returns True if it existed."""
|
"""Stop and remove a session. Returns True if it existed."""
|
||||||
session = _sessions.pop(session_id, None)
|
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
|
#!/usr/bin/env bash
|
||||||
# manage.sh — Linnet dev management script
|
# manage.sh — Linnet dev/demo/cloud management script
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
CMD=${1:-help}
|
CMD=${1:-help}
|
||||||
|
PROFILE=${2:-dev} # dev | demo | cloud
|
||||||
API_PORT=${LINNET_PORT:-8522}
|
API_PORT=${LINNET_PORT:-8522}
|
||||||
FE_PORT=${LINNET_FRONTEND_PORT:-8521}
|
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() {
|
_check_env() {
|
||||||
if [[ ! -f .env ]]; then
|
if [[ ! -f .env ]]; then
|
||||||
echo "No .env found — copying .env.example"
|
warn "No .env found — copying .env.example"
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
fi
|
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
|
case "$CMD" in
|
||||||
|
|
||||||
start)
|
start)
|
||||||
_check_env
|
_check_env
|
||||||
echo "Starting Linnet API on :${API_PORT} (mock mode)…"
|
if [[ "$PROFILE" == "dev" ]]; then
|
||||||
CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \
|
info "Starting Linnet API on :${API_PORT} (mock mode, dev)…"
|
||||||
--host 0.0.0.0 --port "$API_PORT" --reload &
|
CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \
|
||||||
echo "Starting Linnet frontend on :${FE_PORT}…"
|
--host 0.0.0.0 --port "$API_PORT" --reload &
|
||||||
cd frontend && npm install --silent && npm run dev &
|
info "Starting Linnet frontend on :${FE_PORT}…"
|
||||||
echo "API: http://localhost:${API_PORT}"
|
cd frontend && npm install --silent && npm run dev &
|
||||||
echo "UI: http://localhost:${FE_PORT}"
|
info "API: http://localhost:${API_PORT}"
|
||||||
wait
|
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)
|
stop)
|
||||||
echo "Stopping Linnet processes…"
|
if [[ "$PROFILE" == "dev" ]]; then
|
||||||
pkill -f "uvicorn app.main:app" 2>/dev/null || true
|
info "Stopping Linnet dev processes…"
|
||||||
pkill -f "vite.*${FE_PORT}" 2>/dev/null || true
|
pkill -f "uvicorn app.main:app" 2>/dev/null || true
|
||||||
echo "Stopped."
|
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)
|
status)
|
||||||
echo -n "API: "
|
case "$PROFILE" in
|
||||||
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"
|
dev)
|
||||||
echo -n "Frontend: "
|
echo -n "API: "
|
||||||
curl -sf "http://localhost:${FE_PORT}" -o /dev/null && echo "running" || echo "not running"
|
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)
|
test)
|
||||||
_check_env
|
_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)
|
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)
|
build)
|
||||||
_check_env
|
info "Building Docker images ($PROFILE)…"
|
||||||
docker compose up -d
|
FLAGS=$(_compose_cmd "$PROFILE")
|
||||||
echo "API: http://localhost:${API_PORT}"
|
# shellcheck disable=SC2086
|
||||||
echo "UI: http://localhost:${FE_PORT}"
|
docker compose $FLAGS build
|
||||||
;;
|
info "Build complete."
|
||||||
|
|
||||||
docker-stop)
|
|
||||||
docker compose down
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
open)
|
open)
|
||||||
xdg-open "http://localhost:${FE_PORT}" 2>/dev/null \
|
case "$PROFILE" in
|
||||||
|| open "http://localhost:${FE_PORT}" 2>/dev/null \
|
demo) URL="http://localhost:8524" ;;
|
||||||
|| echo "Open http://localhost:${FE_PORT} in your browser"
|
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"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
*)
|
help|*)
|
||||||
echo "Usage: $0 {start|stop|status|test|logs|docker-start|docker-stop|open}"
|
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
|
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