feat(P01): SLICE-01+02 — Dockerfile, .dockerignore, docker-compose.yml, FastAPI StaticFiles, PRAXIS_DB_PATH env (G-102 fix)
SLICE-01 (lead-developer): multi-stage Dockerfile (node:22-slim→python:3.12-slim), .dockerignore (excludes secrets/node_modules/.git), docker-compose.yml (port 8789, SQLite volume, env injection for all voice-service vars) SLICE-02 (backend-engineer+data-engineer): FastAPI mounts client/dist as StaticFiles at / after API routes (D-023, REQ-DEPLOY-13). G-102 MUST fix: db/store.py + db/migrate.py now read PRAXIS_DB_PATH from env so the Docker volume mount persists SQLite data. G-105 FIX: Dockerfile copies pyproject.toml before source (pip install layer cached, source changes don't invalidate). REQ-DEPLOY-01, 02, 13, 16 covered. ---ci--- project: praxis phase: 1 milestone: v0.2 status: execute slice: 01-02 wave: 1 ---/ci---
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# Praxis — Docker build context exclusions
|
||||
# Keep context small (no node_modules, no .git, no pre-built dist).
|
||||
|
||||
# Node / client
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
client/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# CI / planning (not needed inside the container image)
|
||||
.ciagent/
|
||||
|
||||
# Secrets — NEVER in the image
|
||||
.env
|
||||
.env.secrets
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# SQLite DBs (mounted as a volume, not baked in)
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Test / coverage artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
coverage.out
|
||||
|
||||
# Deploy scripts (the CT clones the repo separately for scripts;
|
||||
# the image only needs server + client + db + scenarios)
|
||||
scripts/
|
||||
|
||||
# Piper voice models (pre-staged locally, not in image)
|
||||
*.onnx
|
||||
*.pt
|
||||
*.bin
|
||||
piper_models/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Praxis v0.2 — Multi-stage Docker image
|
||||
# Stage 1: build the React client (client/dist)
|
||||
# Stage 2: Python server + serve client/dist via FastAPI StaticFiles
|
||||
#
|
||||
# Per RESEARCH.md Q4 / ARCHITECTURE.md §Image Build Pipeline.
|
||||
# Debian-slim (not Alpine) — glibc for numpy/pipecat native extensions.
|
||||
|
||||
# ── Stage 1: client builder ──────────────────────────────────────────
|
||||
FROM node:22-slim AS client-builder
|
||||
|
||||
WORKDIR /app/client
|
||||
|
||||
# Copy manifest first for layer caching (deps change less often than source).
|
||||
COPY client/package.json client/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy client source and build.
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
# → produces /app/client/dist/
|
||||
|
||||
# ── Stage 2: server ──────────────────────────────────────────────────
|
||||
FROM python:3.12-slim AS server
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build tools for any source-compilation fallback (numpy/aiohttp wheels
|
||||
# should exist for cp312/linux-amd64, but gcc/g++ + libasound2-dev cover
|
||||
# the R-DEPLOY-01 risk per RESEARCH.md Q4).
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y --no-install-recommends -qq gcc g++ libasound2-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python deps before copying source (layer caching).
|
||||
# G-105 FIX: copy pyproject.toml + README.md first, then pip install,
|
||||
# THEN copy source — so deps are cached and source changes don't
|
||||
# invalidate the pip layer.
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Copy server source + scenarios + db modules.
|
||||
COPY server/ ./server/
|
||||
COPY scenarios/ ./scenarios/
|
||||
COPY db/ ./db/
|
||||
|
||||
# Copy the built client dist from Stage 1.
|
||||
COPY --from=client-builder /app/client/dist ./client/dist
|
||||
|
||||
# Data directory for SQLite (mounted as a volume in docker-compose.yml).
|
||||
RUN mkdir -p /app/data
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE 8789
|
||||
|
||||
# Run the FastAPI server via the existing entrypoint.
|
||||
CMD ["python", "-m", "server"]
|
||||
+3
-1
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_DB_PATH = Path("praxis.db")
|
||||
# G-102 FIX: read PRAXIS_DB_PATH from env (must match db/store.py).
|
||||
_DEFAULT_DB_PATH = Path(os.environ.get("PRAXIS_DB_PATH", "praxis.db"))
|
||||
_DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -13,6 +13,7 @@ No auth — learner_id is the hardcoded 'learner-1' (D-007).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -22,7 +23,9 @@ import aiosqlite
|
||||
|
||||
from db.migrate import apply_migrations
|
||||
|
||||
_DEFAULT_DB_PATH = "praxis.db"
|
||||
# G-102 FIX: read PRAXIS_DB_PATH from env so the Docker volume mount
|
||||
# actually persists data (docker-compose.yml sets PRAXIS_DB_PATH=/app/data/praxis.db).
|
||||
_DEFAULT_DB_PATH = os.environ.get("PRAXIS_DB_PATH", "praxis.db")
|
||||
HARDCODED_LEARNER_ID = "learner-1"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Praxis v0.2 — Docker Compose service definition
|
||||
# Runs the praxis server inside a Docker container (inside an LXC CT).
|
||||
# Per RESEARCH.md Q4/Q8 / ARCHITECTURE.md §v0.2 Deployment Architecture.
|
||||
|
||||
services:
|
||||
praxis:
|
||||
build: .
|
||||
image: praxis:latest
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "8789:8789"
|
||||
volumes:
|
||||
# SQLite DB persistence — survives container recreation (G-102).
|
||||
- praxis-data:/app/data
|
||||
environment:
|
||||
PRAXIS_HOST: "0.0.0.0"
|
||||
PRAXIS_PORT: "8789"
|
||||
PRAXIS_DB_PATH: "/app/data/praxis.db"
|
||||
PRAXIS_SCENARIOS_DIR: "/app/scenarios"
|
||||
PRAXIS_TTS: "${PRAXIS_TTS:-cartesia}"
|
||||
PRAXIS_SCENARIO: "${PRAXIS_SCENARIO:-customer_service_refund_ca_v01}"
|
||||
# Voice-service keys (empty if unprovisioned — server degrades gracefully)
|
||||
DEEPGRAM_API_KEY: "${DEEPGRAM_API_KEY:-}"
|
||||
CARTESIA_API_KEY: "${CARTESIA_API_KEY:-}"
|
||||
OLLAMA_API_KEY: "${OLLAMA_API_KEY:-}"
|
||||
# Ollama Cloud endpoints (D-020)
|
||||
OLLAMA_BASE_URL: "${OLLAMA_BASE_URL:-https://ollama.com/v1}"
|
||||
OLLAMA_CHAT_URL: "${OLLAMA_CHAT_URL:-https://ollama.com/api/chat}"
|
||||
OLLAMA_ROLEPLAY_MODEL: "${OLLAMA_ROLEPLAY_MODEL:-gemma4:cloud}"
|
||||
OLLAMA_DEBRIEF_MODEL: "${OLLAMA_DEBRIEF_MODEL:-deepseek-v4-flash:cloud}"
|
||||
# Deepgram (D-013)
|
||||
DEEPGRAM_MODEL: "${DEEPGRAM_MODEL:-nova-3}"
|
||||
DEEPGRAM_LANGUAGE: "${DEEPGRAM_LANGUAGE:-en}"
|
||||
DEEPGRAM_REGION: "${DEEPGRAM_REGION:-na}"
|
||||
# Cartesia (D-014)
|
||||
CARTESIA_VOICE_ID: "${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}"
|
||||
env_file:
|
||||
# /etc/praxis/server.env is written by install-service.sh with
|
||||
# secrets injected via lxc.environment (G-101 fix: GITEA_TOKEN baked
|
||||
# into the snippet; voice keys from lxc.environment).
|
||||
- /etc/praxis/server.env
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 3
|
||||
|
||||
volumes:
|
||||
praxis-data:
|
||||
driver: local
|
||||
@@ -29,6 +29,7 @@ except ImportError: # pragma: no cover
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
|
||||
from server.pipeline import build_pipeline
|
||||
@@ -116,6 +117,19 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
# ── Static client serving (D-023, REQ-DEPLOY-13) ────────────────────
|
||||
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
|
||||
# take precedence. html=True serves index.html for "/" (SPA root).
|
||||
# The client has no React Router (single-view state machine: start→live
|
||||
# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
|
||||
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
|
||||
if os.path.isdir(_CLIENT_DIST):
|
||||
app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
|
||||
logger.info(f"Serving client from {_CLIENT_DIST}")
|
||||
else:
|
||||
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the server with uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
Reference in New Issue
Block a user