diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2380ea5..1852eff 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -35,10 +35,14 @@ Decimal phases appear between their surrounding integers in numeric order. 4. API endpoints enforce rate limiting and CSRF protection 5. ISO builds execute in sandboxed containers (systemd-nspawn) with no host access 6. Build environment produces deterministic ISOs (identical input = identical hash) -**Plans**: TBD +**Plans**: 5 plans Plans: -- [ ] 01-01: TBD (during phase planning) +- [ ] 01-01-PLAN.md — FastAPI project setup with health endpoints +- [ ] 01-02-PLAN.md — PostgreSQL database with async SQLAlchemy and Alembic +- [ ] 01-03-PLAN.md — Security middleware (rate limiting, CSRF, headers) +- [ ] 01-04-PLAN.md — Caddy HTTPS and database backup automation +- [ ] 01-05-PLAN.md — systemd-nspawn sandbox with deterministic builds ### Phase 2: Overlay System Foundation **Goal**: Layer-based configuration system with dependency tracking and composition @@ -185,7 +189,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Core Infrastructure & Security | 0/TBD | Not started | - | +| 1. Core Infrastructure & Security | 0/5 | Planned | - | | 2. Overlay System Foundation | 0/TBD | Not started | - | | 3. Build Queue & Workers | 0/TBD | Not started | - | | 4. User Accounts | 0/TBD | Not started | - | diff --git a/.planning/phases/01-core-infrastructure-security/01-01-PLAN.md b/.planning/phases/01-core-infrastructure-security/01-01-PLAN.md new file mode 100644 index 0000000..f9dea0f --- /dev/null +++ b/.planning/phases/01-core-infrastructure-security/01-01-PLAN.md @@ -0,0 +1,206 @@ +--- +phase: 01-core-infrastructure-security +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - pyproject.toml + - backend/app/__init__.py + - backend/app/main.py + - backend/app/core/__init__.py + - backend/app/core/config.py + - backend/app/api/__init__.py + - backend/app/api/v1/__init__.py + - backend/app/api/v1/router.py + - backend/app/api/v1/endpoints/__init__.py + - backend/app/api/v1/endpoints/health.py + - .env.example +autonomous: true + +must_haves: + truths: + - "FastAPI app starts without errors" + - "Health endpoint returns 200 OK" + - "Configuration loads from environment variables" + - "Project dependencies install via uv" + artifacts: + - path: "pyproject.toml" + provides: "Project configuration and dependencies" + contains: "fastapi" + - path: "backend/app/main.py" + provides: "FastAPI application entry point" + exports: ["app"] + - path: "backend/app/core/config.py" + provides: "Application configuration via pydantic-settings" + contains: "BaseSettings" + - path: "backend/app/api/v1/endpoints/health.py" + provides: "Health check endpoint" + contains: "@router.get" + key_links: + - from: "backend/app/main.py" + to: "backend/app/api/v1/router.py" + via: "include_router" + pattern: "app\\.include_router" + - from: "backend/app/api/v1/router.py" + to: "backend/app/api/v1/endpoints/health.py" + via: "include_router" + pattern: "router\\.include_router" +--- + + +Establish the FastAPI backend project structure with configuration management and basic health endpoint. + +Purpose: Create the foundational Python project that all subsequent infrastructure builds upon. +Output: A runnable FastAPI application with proper project structure, dependency management via uv, and environment-based configuration. + + + +@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md +@/home/mikkel/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/01-core-infrastructure-security/01-RESEARCH.md (Standard Stack section, Architecture Patterns section) + + + + + + Task 1: Initialize Python project with uv and dependencies + pyproject.toml, .env.example + +Create pyproject.toml with: +- Project name: debate-backend +- Python version: >=3.12 +- Dependencies from research standard stack: + - fastapi[all]>=0.128.0 + - uvicorn[standard]>=0.30.0 + - sqlalchemy[asyncio]>=2.0.0 + - asyncpg<0.29.0 + - alembic + - pydantic>=2.12.0 + - pydantic-settings + - slowapi + - fastapi-csrf-protect + - python-multipart +- Dev dependencies: + - pytest + - pytest-asyncio + - pytest-cov + - httpx + - ruff + - mypy + +Configure ruff in pyproject.toml: +- line-length = 88 +- target-version = "py312" +- select = ["E", "F", "I", "N", "W", "UP"] + +Create .env.example with documented environment variables: +- DATABASE_URL (postgresql+asyncpg://...) +- SECRET_KEY (for JWT/CSRF) +- ENVIRONMENT (development/production) +- DEBUG (true/false) +- ALLOWED_HOSTS (comma-separated) +- ALLOWED_ORIGINS (comma-separated, for CORS) + +Initialize project with uv: `uv venv && uv pip install -e ".[dev]"` + + +Run: `cd /home/mikkel/repos/debate && uv pip list | grep -E "(fastapi|uvicorn|sqlalchemy|pydantic)"` +Expected: All core dependencies listed with correct versions. + + +pyproject.toml exists with all specified dependencies, virtual environment created, packages installed. + + + + + Task 2: Create FastAPI application structure with health endpoint + +backend/app/__init__.py +backend/app/main.py +backend/app/core/__init__.py +backend/app/core/config.py +backend/app/api/__init__.py +backend/app/api/v1/__init__.py +backend/app/api/v1/router.py +backend/app/api/v1/endpoints/__init__.py +backend/app/api/v1/endpoints/health.py + + +Create directory structure following research architecture: +``` +backend/ + app/ + __init__.py + main.py + core/ + __init__.py + config.py + api/ + __init__.py + v1/ + __init__.py + router.py + endpoints/ + __init__.py + health.py +``` + +backend/app/core/config.py: +- Use pydantic-settings BaseSettings +- Load: database_url, secret_key, environment, debug, allowed_hosts, allowed_origins +- Parse allowed_hosts and allowed_origins as lists (comma-separated in env) +- Set sensible defaults for development + +backend/app/main.py: +- Create FastAPI app with title="Debate API", version="1.0.0" +- Disable docs in production (docs_url=None if production) +- Include v1 router at /api/v1 prefix +- Add basic health endpoint at root /health (outside versioned API) + +backend/app/api/v1/router.py: +- Create APIRouter +- Include health endpoint router with prefix="/health", tags=["health"] + +backend/app/api/v1/endpoints/health.py: +- GET /health returns {"status": "healthy"} +- GET /health/ready for readiness check (will add DB check in next plan) + +All __init__.py files should be empty (or contain only necessary imports). + + +Run: `cd /home/mikkel/repos/debate && source .venv/bin/activate && uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 &` +Wait 2 seconds, then: `curl -s http://localhost:8000/health | grep -q healthy && echo "Health check passed"` +Kill the server. + + +FastAPI application starts, health endpoint returns {"status": "healthy"}. + + + + + + +1. `uv pip list` shows all dependencies at correct versions +2. `ruff check backend/` passes with no errors +3. `uvicorn backend.app.main:app` starts without errors +4. `curl http://localhost:8000/health` returns 200 with {"status": "healthy"} +5. `curl http://localhost:8000/api/v1/health` returns 200 + + + +- FastAPI backend structure exists following research architecture +- All dependencies installed via uv +- Health endpoint responds at /health +- Configuration loads from environment (or .env file) +- ruff passes on all code + + + +After completion, create `.planning/phases/01-core-infrastructure-security/01-01-SUMMARY.md` + diff --git a/.planning/phases/01-core-infrastructure-security/01-02-PLAN.md b/.planning/phases/01-core-infrastructure-security/01-02-PLAN.md new file mode 100644 index 0000000..e304eae --- /dev/null +++ b/.planning/phases/01-core-infrastructure-security/01-02-PLAN.md @@ -0,0 +1,208 @@ +--- +phase: 01-core-infrastructure-security +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/app/db/__init__.py + - backend/app/db/base.py + - backend/app/db/session.py + - backend/app/db/models/__init__.py + - backend/app/db/models/build.py + - backend/alembic.ini + - backend/alembic/env.py + - backend/alembic/script.py.mako + - backend/alembic/versions/.gitkeep + - docker-compose.yml +autonomous: true + +must_haves: + truths: + - "PostgreSQL container starts and accepts connections" + - "Alembic migrations run without errors" + - "Database session factory creates async sessions" + - "Build model persists to database" + artifacts: + - path: "backend/app/db/session.py" + provides: "Async database session factory" + contains: "async_sessionmaker" + - path: "backend/app/db/base.py" + provides: "SQLAlchemy declarative base" + contains: "DeclarativeBase" + - path: "backend/app/db/models/build.py" + provides: "Build tracking model" + contains: "class Build" + - path: "backend/alembic/env.py" + provides: "Alembic migration environment" + contains: "run_migrations_online" + - path: "docker-compose.yml" + provides: "PostgreSQL container configuration" + contains: "postgres" + key_links: + - from: "backend/app/db/session.py" + to: "backend/app/core/config.py" + via: "settings.database_url" + pattern: "settings\\.database_url" + - from: "backend/alembic/env.py" + to: "backend/app/db/base.py" + via: "target_metadata" + pattern: "target_metadata.*Base\\.metadata" +--- + + +Set up PostgreSQL database with async SQLAlchemy, Alembic migrations, and initial build tracking model. + +Purpose: Establish the data persistence layer that tracks builds, users, and configurations. +Output: Running PostgreSQL instance, async session factory, and migration infrastructure with initial Build model. + + + +@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md +@/home/mikkel/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/01-core-infrastructure-security/01-RESEARCH.md (Pattern 1: Async Database Session Management, Code Examples: Database Migrations with Alembic) + + + + + + Task 1: Set up PostgreSQL with Docker and async session factory + +docker-compose.yml +backend/app/db/__init__.py +backend/app/db/base.py +backend/app/db/session.py + + +Create docker-compose.yml: +- PostgreSQL 18 service (postgres:18-alpine image if available, or postgres:16-alpine) +- Container name: debate-postgres +- Environment: POSTGRES_USER=debate, POSTGRES_PASSWORD=debate_dev, POSTGRES_DB=debate +- Port: 5432:5432 +- Volume: postgres_data for persistence +- Health check on pg_isready + +backend/app/db/__init__.py: +- Empty or re-export key items + +backend/app/db/base.py: +- Create SQLAlchemy 2.0 DeclarativeBase +- Import all models (for Alembic autogenerate) +- Pattern: `class Base(DeclarativeBase): pass` + +backend/app/db/session.py: +- Import settings from core.config +- Create async engine with connection pooling (from research): + - pool_size=10 + - max_overflow=20 + - pool_timeout=30 + - pool_recycle=1800 + - pool_pre_ping=True +- Create async_sessionmaker factory +- Create `get_db` async generator dependency for FastAPI + +Update .env.example (if not already done): +- DATABASE_URL=postgresql+asyncpg://debate:debate_dev@localhost:5432/debate + + +Run: `cd /home/mikkel/repos/debate && docker compose up -d` +Wait 5 seconds for postgres to start. +Run: `docker compose exec postgres pg_isready -U debate` +Expected: "accepting connections" + + +PostgreSQL container running, async session factory configured with connection pooling. + + + + + Task 2: Configure Alembic and create Build model + +backend/alembic.ini +backend/alembic/env.py +backend/alembic/script.py.mako +backend/alembic/versions/.gitkeep +backend/app/db/models/__init__.py +backend/app/db/models/build.py + + +Initialize Alembic in backend directory: +```bash +cd backend && alembic init alembic +``` + +Modify backend/alembic.ini: +- Set script_location = alembic +- Remove sqlalchemy.url (we'll set it from config) + +Modify backend/alembic/env.py: +- Import asyncio, async_engine_from_config +- Import settings from app.core.config +- Import Base from app.db.base (this imports all models) +- Set sqlalchemy.url from settings.database_url +- Implement run_migrations_online() as async function (from research) +- Use asyncio.run() for async migrations + +Create backend/app/db/models/__init__.py: +- Import all models for Alembic discovery + +Create backend/app/db/models/build.py: +- Build model with fields: + - id: UUID primary key (use uuid.uuid4) + - config_hash: String(64), unique, indexed (SHA-256 of configuration) + - status: Enum (pending, building, completed, failed, cached) + - iso_path: Optional String (path to generated ISO) + - error_message: Optional Text (for failed builds) + - build_log: Optional Text (full build output) + - started_at: DateTime (nullable, set when build starts) + - completed_at: DateTime (nullable, set when build finishes) + - created_at: DateTime with server default now() + - updated_at: DateTime with onupdate +- Add index on status for queue queries +- Add index on config_hash for cache lookups + +Update backend/app/db/base.py to import Build model. + +Generate and run initial migration: +```bash +cd backend && alembic revision --autogenerate -m "Create build table" +cd backend && alembic upgrade head +``` + + +Run: `cd /home/mikkel/repos/debate/backend && alembic current` +Expected: Shows current migration head. +Run: `docker compose exec postgres psql -U debate -d debate -c "\\dt"` +Expected: Shows "builds" table. + + +Alembic configured for async, Build model created with migration applied. + + + + + + +1. `docker compose ps` shows postgres container running and healthy +2. `cd backend && alembic current` shows migration applied +3. `docker compose exec postgres psql -U debate -d debate -c "SELECT * FROM builds LIMIT 1;"` succeeds (empty result OK) +4. `ruff check backend/app/db/` passes +5. Database has builds table with correct columns + + + +- PostgreSQL 18 running in Docker with health checks +- Async session factory with proper connection pooling +- Alembic configured for async migrations +- Build model exists with config_hash, status, timestamps +- Initial migration applied successfully + + + +After completion, create `.planning/phases/01-core-infrastructure-security/01-02-SUMMARY.md` + diff --git a/.planning/phases/01-core-infrastructure-security/01-03-PLAN.md b/.planning/phases/01-core-infrastructure-security/01-03-PLAN.md new file mode 100644 index 0000000..d28dd8b --- /dev/null +++ b/.planning/phases/01-core-infrastructure-security/01-03-PLAN.md @@ -0,0 +1,189 @@ +--- +phase: 01-core-infrastructure-security +plan: 03 +type: execute +wave: 2 +depends_on: ["01-01", "01-02"] +files_modified: + - backend/app/main.py + - backend/app/core/security.py + - backend/app/api/deps.py + - backend/app/api/v1/endpoints/health.py +autonomous: true + +must_haves: + truths: + - "Rate limiting blocks requests exceeding 100/minute" + - "CSRF tokens are validated on state-changing requests" + - "Database connectivity checked in health endpoint" + - "Security headers present in responses" + artifacts: + - path: "backend/app/core/security.py" + provides: "Rate limiting and CSRF configuration" + contains: "Limiter" + - path: "backend/app/api/deps.py" + provides: "FastAPI dependency injection" + contains: "get_db" + - path: "backend/app/main.py" + provides: "Security middleware stack" + contains: "TrustedHostMiddleware" + key_links: + - from: "backend/app/main.py" + to: "backend/app/core/security.py" + via: "limiter import" + pattern: "from app\\.core\\.security import" + - from: "backend/app/api/v1/endpoints/health.py" + to: "backend/app/api/deps.py" + via: "Depends(get_db)" + pattern: "Depends\\(get_db\\)" +--- + + +Implement security middleware stack with rate limiting, CSRF protection, and security headers. + +Purpose: Protect the API from abuse and common web vulnerabilities (INFR-06, INFR-07). +Output: FastAPI application with layered security: rate limiting (100/min), CSRF protection, trusted hosts, CORS, and security headers. + + + +@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md +@/home/mikkel/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/01-core-infrastructure-security/01-RESEARCH.md (Pattern 3: FastAPI Security Middleware Stack) +@.planning/phases/01-core-infrastructure-security/01-01-SUMMARY.md +@.planning/phases/01-core-infrastructure-security/01-02-SUMMARY.md + + + + + + Task 1: Configure rate limiting and CSRF protection + +backend/app/core/security.py +backend/app/api/deps.py + + +Create backend/app/core/security.py: +- Import and configure slowapi Limiter: + - key_func=get_remote_address + - default_limits=["100/minute"] + - storage_uri from settings (default to memory, Redis for production) +- Configure fastapi-csrf-protect CsrfProtect: + - Create CsrfSettings Pydantic model with: + - secret_key from settings + - cookie_samesite = "lax" + - cookie_secure = True (HTTPS only) + - cookie_httponly = True + - Implement @CsrfProtect.load_config decorator + +Create backend/app/api/deps.py: +- Import get_db from app.db.session +- Re-export for cleaner imports in endpoints +- Create optional dependency for CSRF validation: + ```python + async def validate_csrf(csrf_protect: CsrfProtect = Depends()): + await csrf_protect.validate_csrf_in_cookies() + ``` + + +Run: `cd /home/mikkel/repos/debate && ruff check backend/app/core/security.py backend/app/api/deps.py` +Expected: No errors. + + +Rate limiter configured at 100/minute, CSRF protection configured with secure cookie settings. + + + + + Task 2: Apply security middleware to FastAPI app and update health endpoint + +backend/app/main.py +backend/app/api/v1/endpoints/health.py + + +Update backend/app/main.py with middleware stack (order matters - first added = outermost): + +1. TrustedHostMiddleware: + - allowed_hosts from settings.allowed_hosts + - Block requests with invalid Host header + +2. CORSMiddleware: + - allow_origins from settings.allowed_origins + - allow_credentials=True + - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"] + - allow_headers=["*"] + - max_age=600 (cache preflight for 10 min) + +3. Rate limiting: + - app.state.limiter = limiter + - Add RateLimitExceeded exception handler + +4. Custom middleware for security headers: + - Strict-Transport-Security: max-age=31536000; includeSubDomains + - X-Content-Type-Options: nosniff + - X-Frame-Options: DENY + - X-XSS-Protection: 1; mode=block + - Referrer-Policy: strict-origin-when-cross-origin + +Update backend/app/api/v1/endpoints/health.py: +- Keep GET /health as simple {"status": "healthy"} +- Add GET /health/db that checks database connectivity: + - Depends on get_db session + - Execute "SELECT 1" query + - Return {"status": "healthy", "database": "connected"} on success + - Return {"status": "unhealthy", "database": "error", "detail": str(e)} on failure +- Add @limiter.exempt decorator to health endpoints (don't rate limit health checks) + + +Start the server and test: +```bash +cd /home/mikkel/repos/debate +source .venv/bin/activate +uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 & +sleep 2 + +# Test health endpoint +curl -s http://localhost:8000/health + +# Test database health +curl -s http://localhost:8000/api/v1/health/db + +# Test security headers +curl -sI http://localhost:8000/health | grep -E "(X-Content-Type|X-Frame|Strict-Transport)" + +# Test rate limiting (make 110 requests) +for i in {1..110}; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/health; done | sort | uniq -c +``` +Expected: First 100+ requests return 200, some return 429 (rate limited). +Kill the server after testing. + + +Security middleware applied, health endpoints check database, rate limiting blocks excess requests. + + + + + + +1. `curl -sI http://localhost:8000/health` includes security headers (X-Content-Type-Options, X-Frame-Options) +2. `curl http://localhost:8000/api/v1/health/db` returns database status +3. Rapid requests (>100/min) return 429 Too Many Requests +4. Invalid Host header returns 400 Bad Request +5. `ruff check backend/` passes + + + +- Rate limiting enforced at 100 requests/minute per IP (INFR-06) +- CSRF protection configured (INFR-07) +- Security headers present in all responses +- Health endpoints verify database connectivity +- All middleware applied in correct order + + + +After completion, create `.planning/phases/01-core-infrastructure-security/01-03-SUMMARY.md` + diff --git a/.planning/phases/01-core-infrastructure-security/01-04-PLAN.md b/.planning/phases/01-core-infrastructure-security/01-04-PLAN.md new file mode 100644 index 0000000..f1e1e24 --- /dev/null +++ b/.planning/phases/01-core-infrastructure-security/01-04-PLAN.md @@ -0,0 +1,298 @@ +--- +phase: 01-core-infrastructure-security +plan: 04 +type: execute +wave: 2 +depends_on: ["01-02"] +files_modified: + - Caddyfile + - docker-compose.yml + - scripts/backup-postgres.sh + - scripts/cron/postgres-backup +autonomous: true + +must_haves: + truths: + - "HTTPS terminates at Caddy with valid certificate" + - "HTTP requests redirect to HTTPS" + - "Database backup script runs successfully" + - "Backup files are created with timestamps" + artifacts: + - path: "Caddyfile" + provides: "Caddy reverse proxy configuration" + contains: "reverse_proxy" + - path: "scripts/backup-postgres.sh" + provides: "Database backup automation" + contains: "pg_dump" + - path: "docker-compose.yml" + provides: "Caddy container configuration" + contains: "caddy" + key_links: + - from: "Caddyfile" + to: "backend/app/main.py" + via: "reverse_proxy localhost:8000" + pattern: "reverse_proxy.*localhost:8000" + - from: "scripts/backup-postgres.sh" + to: "docker-compose.yml" + via: "debate-postgres container" + pattern: "docker.*exec.*postgres" +--- + + +Configure Caddy for HTTPS termination and set up PostgreSQL daily backup automation. + +Purpose: Ensure all traffic is encrypted (INFR-05) and user data is backed up daily (INFR-04). +Output: Caddy reverse proxy with automatic HTTPS, PostgreSQL backup script with 30-day retention. + + + +@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md +@/home/mikkel/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/01-core-infrastructure-security/01-RESEARCH.md (Pattern 2: Caddy Automatic HTTPS, Code Examples: PostgreSQL Backup Script) +@.planning/phases/01-core-infrastructure-security/01-CONTEXT.md (Backup & Recovery decisions) +@.planning/phases/01-core-infrastructure-security/01-02-SUMMARY.md + + + + + + Task 1: Configure Caddy reverse proxy with HTTPS + +Caddyfile +docker-compose.yml + + +Create Caddyfile in project root: +```caddyfile +{ + # Admin API for programmatic route management (future use for ISO downloads) + admin localhost:2019 + + # For local development, use internal CA + # In production, Caddy auto-obtains Let's Encrypt certs +} + +# Development configuration (localhost) +:443 { + tls internal # Self-signed for local dev + + # Reverse proxy to FastAPI + reverse_proxy localhost:8000 { + health_uri /health + health_interval 10s + health_timeout 5s + } + + # Security headers (supplement FastAPI's headers) + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + } + + # Access logging + log { + output file /var/log/caddy/access.log { + roll_size 100mb + roll_keep 10 + } + format json + } +} + +# HTTP to HTTPS redirect +:80 { + redir https://{host}{uri} permanent +} +``` + +Update docker-compose.yml to add Caddy service: +```yaml +services: + caddy: + image: caddy:2-alpine + container_name: debate-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "2019:2019" # Admin API (bind to localhost in production) + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + - caddy_logs:/var/log/caddy + network_mode: host # To reach localhost:8000 + +volumes: + caddy_data: + caddy_config: + caddy_logs: +``` + +Note: For development, Caddy uses self-signed certs (`tls internal`). +For production, replace `:443` with actual domain and remove `tls internal`. + + +Run: +```bash +cd /home/mikkel/repos/debate +docker compose up -d caddy +sleep 3 +# Test HTTPS (allow self-signed cert) +curl -sk https://localhost/health +# Test HTTP redirect +curl -sI http://localhost | grep -i location +``` +Expected: HTTPS returns health response, HTTP redirects to HTTPS. + + +Caddy running with HTTPS termination, HTTP redirects to HTTPS. + + + + + Task 2: Create PostgreSQL backup script with retention + +scripts/backup-postgres.sh +scripts/cron/postgres-backup + + +Create scripts/backup-postgres.sh: +```bash +#!/bin/bash +# PostgreSQL backup script for Debate platform +# Runs daily, keeps 30 days of backups +# Verifies backup integrity after creation + +set -euo pipefail + +# Configuration +BACKUP_DIR="${BACKUP_DIR:-/var/backups/debate/postgres}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +CONTAINER_NAME="${CONTAINER_NAME:-debate-postgres}" +DB_NAME="${DB_NAME:-debate}" +DB_USER="${DB_USER:-debate}" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.dump" + +# Logging +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +# Create backup directory +mkdir -p "$BACKUP_DIR" + +log "Starting backup of database: $DB_NAME" + +# Create backup using pg_dump custom format (-Fc) +# Custom format is compressed and allows selective restore +docker exec "$CONTAINER_NAME" pg_dump \ + -U "$DB_USER" \ + -Fc \ + -b \ + -v \ + "$DB_NAME" > "$BACKUP_FILE" 2>&1 + +log "Backup created: $BACKUP_FILE" + +# Verify backup integrity +log "Verifying backup integrity..." +docker exec -i "$CONTAINER_NAME" pg_restore \ + --list "$BACKUP_FILE" > /dev/null 2>&1 || { + log "ERROR: Backup verification failed!" + rm -f "$BACKUP_FILE" + exit 1 +} + +# Get backup size +BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) +log "Backup size: $BACKUP_SIZE" + +# Compress if not already (pg_dump -Fc includes compression, but this adds more) +gzip -f "$BACKUP_FILE" +log "Compressed: ${BACKUP_FILE}.gz" + +# Clean up old backups +log "Removing backups older than $RETENTION_DAYS days..." +find "$BACKUP_DIR" -name "${DB_NAME}_*.dump.gz" -mtime +$RETENTION_DAYS -delete +REMAINING=$(find "$BACKUP_DIR" -name "${DB_NAME}_*.dump.gz" | wc -l) +log "Remaining backups: $REMAINING" + +# Weekly restore test (every Monday) +if [ "$(date +%u)" -eq 1 ]; then + log "Running weekly restore test..." + TEST_DB="${DB_NAME}_backup_test" + + # Create test database + docker exec "$CONTAINER_NAME" createdb -U "$DB_USER" "$TEST_DB" 2>/dev/null || true + + # Restore to test database + gunzip -c "${BACKUP_FILE}.gz" | docker exec -i "$CONTAINER_NAME" pg_restore \ + -U "$DB_USER" \ + -d "$TEST_DB" \ + --clean \ + --if-exists 2>&1 || true + + # Drop test database + docker exec "$CONTAINER_NAME" dropdb -U "$DB_USER" "$TEST_DB" 2>/dev/null || true + + log "Weekly restore test completed" +fi + +log "Backup completed successfully" +``` + +Make executable: `chmod +x scripts/backup-postgres.sh` + +Create scripts/cron/postgres-backup: +``` +# PostgreSQL daily backup at 2 AM +0 2 * * * /home/mikkel/repos/debate/scripts/backup-postgres.sh >> /var/log/debate/postgres-backup.log 2>&1 +``` + +Create .gitignore entry for backup files (they shouldn't be in repo). + + +Run: +```bash +cd /home/mikkel/repos/debate +mkdir -p /tmp/debate-backups +BACKUP_DIR=/tmp/debate-backups ./scripts/backup-postgres.sh +ls -la /tmp/debate-backups/ +``` +Expected: Backup file created with .dump.gz extension. + + +Backup script creates compressed PostgreSQL dumps, verifies integrity, maintains 30-day retention. + + + + + + +1. `curl -sk https://localhost/health` returns healthy through Caddy +2. `curl -sI http://localhost | grep -i location` shows HTTPS redirect +3. `./scripts/backup-postgres.sh` creates backup successfully +4. Backup file is compressed and verifiable +5. Old backups (>30 days) would be deleted by retention logic + + + +- All traffic flows through HTTPS via Caddy (INFR-05) +- HTTP requests redirect to HTTPS +- Caddy health checks FastAPI backend +- Daily backup script exists with 30-day retention (INFR-04) +- Backup integrity verified after creation +- Weekly restore test configured + + + +After completion, create `.planning/phases/01-core-infrastructure-security/01-04-SUMMARY.md` + diff --git a/.planning/phases/01-core-infrastructure-security/01-05-PLAN.md b/.planning/phases/01-core-infrastructure-security/01-05-PLAN.md new file mode 100644 index 0000000..395eb07 --- /dev/null +++ b/.planning/phases/01-core-infrastructure-security/01-05-PLAN.md @@ -0,0 +1,743 @@ +--- +phase: 01-core-infrastructure-security +plan: 05 +type: execute +wave: 3 +depends_on: ["01-01", "01-02"] +files_modified: + - backend/app/services/__init__.py + - backend/app/services/sandbox.py + - backend/app/services/deterministic.py + - backend/app/services/build.py + - scripts/setup-sandbox.sh + - tests/test_deterministic.py +autonomous: true + +must_haves: + truths: + - "Sandbox creates isolated systemd-nspawn container" + - "Build commands execute with no network access" + - "Same configuration produces identical hash" + - "SOURCE_DATE_EPOCH is set for all builds" + artifacts: + - path: "backend/app/services/sandbox.py" + provides: "systemd-nspawn sandbox management" + contains: "systemd-nspawn" + - path: "backend/app/services/deterministic.py" + provides: "Deterministic build configuration" + contains: "SOURCE_DATE_EPOCH" + - path: "backend/app/services/build.py" + provides: "Build orchestration service" + contains: "class BuildService" + - path: "scripts/setup-sandbox.sh" + provides: "Sandbox environment initialization" + contains: "pacstrap" + key_links: + - from: "backend/app/services/build.py" + to: "backend/app/services/sandbox.py" + via: "BuildSandbox import" + pattern: "from.*sandbox import" + - from: "backend/app/services/build.py" + to: "backend/app/services/deterministic.py" + via: "DeterministicBuildConfig import" + pattern: "from.*deterministic import" +--- + + +Implement systemd-nspawn build sandbox with deterministic configuration for reproducible ISO builds. + +Purpose: Ensure ISO builds are isolated from host (ISO-04) and produce identical output for same input (determinism for caching). +Output: Sandbox service that creates isolated containers, deterministic build configuration with hash generation. + + + +@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md +@/home/mikkel/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/01-core-infrastructure-security/01-RESEARCH.md (Pattern 4: systemd-nspawn Build Sandbox, Pattern 5: Deterministic Build Configuration) +@.planning/phases/01-core-infrastructure-security/01-CONTEXT.md (Sandbox Strictness, Determinism Approach decisions) +@.planning/phases/01-core-infrastructure-security/01-01-SUMMARY.md +@.planning/phases/01-core-infrastructure-security/01-02-SUMMARY.md + + + + + + Task 1: Create sandbox setup script and sandbox service + +scripts/setup-sandbox.sh +backend/app/services/__init__.py +backend/app/services/sandbox.py + + +Create scripts/setup-sandbox.sh: +```bash +#!/bin/bash +# Initialize sandbox environment for ISO builds +# Run once to create base container image + +set -euo pipefail + +SANDBOX_ROOT="${SANDBOX_ROOT:-/var/lib/debate/sandbox}" +SANDBOX_BASE="${SANDBOX_ROOT}/base" +ALLOWED_MIRRORS=( + "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch" + "https://mirror.cachyos.org/repo/\$arch/\$repo" +) + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +# Check prerequisites +if ! command -v pacstrap &> /dev/null; then + log "ERROR: pacstrap not found. Install arch-install-scripts package." + exit 1 +fi + +if ! command -v systemd-nspawn &> /dev/null; then + log "ERROR: systemd-nspawn not found. Install systemd-container package." + exit 1 +fi + +# Create sandbox directories +log "Creating sandbox directories..." +mkdir -p "$SANDBOX_ROOT"/{base,builds,cache} + +# Bootstrap base Arch environment +if [ ! -d "$SANDBOX_BASE/usr" ]; then + log "Bootstrapping base Arch Linux environment..." + pacstrap -c -G -M "$SANDBOX_BASE" base archiso + + # Configure mirrors (whitelist only) + log "Configuring mirrors..." + MIRRORLIST="$SANDBOX_BASE/etc/pacman.d/mirrorlist" + : > "$MIRRORLIST" + for mirror in "${ALLOWED_MIRRORS[@]}"; do + echo "Server = $mirror" >> "$MIRRORLIST" + done + + # Set fixed locale for determinism + echo "en_US.UTF-8 UTF-8" > "$SANDBOX_BASE/etc/locale.gen" + systemd-nspawn -D "$SANDBOX_BASE" locale-gen + + log "Base environment created at $SANDBOX_BASE" +else + log "Base environment already exists at $SANDBOX_BASE" +fi + +log "Sandbox setup complete" +``` + +Create backend/app/services/__init__.py: +- Empty or import key services + +Create backend/app/services/sandbox.py: +```python +""" +systemd-nspawn sandbox for isolated ISO builds. + +Security measures: +- --private-network: No network access (packages pre-cached in base) +- --read-only: Immutable root filesystem +- --tmpfs: Writable temp directories only +- --capability: Minimal capabilities for mkarchiso +- Resource limits: 8GB RAM, 4 cores (from CONTEXT.md) +""" + +import asyncio +import shutil +import subprocess +from pathlib import Path +from typing import Optional +from dataclasses import dataclass +from datetime import datetime + +from app.core.config import settings + + +@dataclass +class SandboxConfig: + """Configuration for sandbox execution.""" + memory_limit: str = "8G" + cpu_quota: str = "400%" # 4 cores + timeout_seconds: int = 1200 # 20 minutes (with 15min warning) + warning_seconds: int = 900 # 15 minutes + + +class BuildSandbox: + """Manages systemd-nspawn sandboxed build environments.""" + + def __init__( + self, + sandbox_root: Path = None, + config: SandboxConfig = None + ): + self.sandbox_root = sandbox_root or Path(settings.sandbox_root) + self.base_path = self.sandbox_root / "base" + self.builds_path = self.sandbox_root / "builds" + self.config = config or SandboxConfig() + + async def create_build_container(self, build_id: str) -> Path: + """ + Create isolated container for a specific build. + Uses overlay filesystem on base for efficiency. + """ + container_path = self.builds_path / build_id + if container_path.exists(): + shutil.rmtree(container_path) + container_path.mkdir(parents=True) + + # Copy base (in production, use overlayfs for efficiency) + # For now, simple copy is acceptable + proc = await asyncio.create_subprocess_exec( + "cp", "-a", str(self.base_path) + "/.", str(container_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + await proc.wait() + + return container_path + + async def run_build( + self, + container_path: Path, + profile_path: Path, + output_path: Path, + source_date_epoch: int + ) -> tuple[int, str, str]: + """ + Execute archiso build in sandboxed container. + + Returns: + Tuple of (return_code, stdout, stderr) + """ + output_path.mkdir(parents=True, exist_ok=True) + + nspawn_cmd = [ + "systemd-nspawn", + f"--directory={container_path}", + "--private-network", # No network access + "--read-only", # Immutable root + "--tmpfs=/tmp:mode=1777", + "--tmpfs=/var/tmp:mode=1777", + f"--bind={profile_path}:/build/profile:ro", + f"--bind={output_path}:/build/output", + f"--setenv=SOURCE_DATE_EPOCH={source_date_epoch}", + "--setenv=LC_ALL=C", + "--setenv=TZ=UTC", + "--capability=CAP_SYS_ADMIN", # Required for mkarchiso + "--console=pipe", + "--quiet", + "--", + "mkarchiso", + "-v", + "-r", # Remove work directory after build + "-w", "/tmp/archiso-work", + "-o", "/build/output", + "/build/profile" + ] + + proc = await asyncio.create_subprocess_exec( + *nspawn_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=self.config.timeout_seconds + ) + return proc.returncode, stdout.decode(), stderr.decode() + except asyncio.TimeoutError: + proc.kill() + return -1, "", f"Build timed out after {self.config.timeout_seconds} seconds" + + async def cleanup_container(self, container_path: Path): + """Remove container after build.""" + if container_path.exists(): + shutil.rmtree(container_path) +``` + + +Run: +```bash +cd /home/mikkel/repos/debate +ruff check backend/app/services/sandbox.py +python -c "from backend.app.services.sandbox import BuildSandbox, SandboxConfig; print('Import OK')" +``` +Expected: No ruff errors, import succeeds. + + +Sandbox service creates isolated containers with network isolation, resource limits, and deterministic environment. + + + + + Task 2: Create deterministic build configuration service + +backend/app/services/deterministic.py +tests/test_deterministic.py + + +Create backend/app/services/deterministic.py: +```python +""" +Deterministic build configuration for reproducible ISOs. + +Critical: Same configuration must produce identical ISO hash. +This is required for caching to work correctly. + +Determinism factors: +- SOURCE_DATE_EPOCH: Fixed timestamps in all generated files +- LC_ALL=C: Fixed locale for sorting +- TZ=UTC: Fixed timezone +- Sorted inputs: Packages, files always in consistent order +- Fixed compression: Consistent squashfs settings +""" + +import hashlib +import json +from pathlib import Path +from typing import Any +from dataclasses import dataclass + + +@dataclass +class OverlayFile: + """A file to be included in the overlay.""" + path: str # Absolute path in ISO (e.g., /etc/skel/.bashrc) + content: str + mode: str = "0644" + + +@dataclass +class BuildConfiguration: + """Normalized build configuration for deterministic hashing.""" + packages: list[str] + overlays: list[dict[str, Any]] + locale: str = "en_US.UTF-8" + timezone: str = "UTC" + + +class DeterministicBuildConfig: + """Ensures reproducible ISO builds.""" + + @staticmethod + def compute_config_hash(config: dict[str, Any]) -> str: + """ + Generate deterministic hash of build configuration. + + Process: + 1. Normalize all inputs (sort lists, normalize paths) + 2. Hash file contents (not file objects) + 3. Use consistent JSON serialization + + Returns: + SHA-256 hash of normalized configuration + """ + # Normalize packages (sorted, deduplicated) + packages = sorted(set(config.get("packages", []))) + + # Normalize overlays + normalized_overlays = [] + for overlay in sorted(config.get("overlays", []), key=lambda x: x.get("name", "")): + normalized_files = [] + for f in sorted(overlay.get("files", []), key=lambda x: x.get("path", "")): + content = f.get("content", "") + content_hash = hashlib.sha256(content.encode()).hexdigest() + normalized_files.append({ + "path": f.get("path", "").strip(), + "content_hash": content_hash, + "mode": f.get("mode", "0644") + }) + normalized_overlays.append({ + "name": overlay.get("name", "").strip(), + "files": normalized_files + }) + + # Build normalized config + normalized = { + "packages": packages, + "overlays": normalized_overlays, + "locale": config.get("locale", "en_US.UTF-8"), + "timezone": config.get("timezone", "UTC") + } + + # JSON with sorted keys for determinism + config_json = json.dumps(normalized, sort_keys=True, separators=(',', ':')) + return hashlib.sha256(config_json.encode()).hexdigest() + + @staticmethod + def get_source_date_epoch(config_hash: str) -> int: + """ + Generate deterministic timestamp from config hash. + + Using hash-derived timestamp ensures: + - Same config always gets same timestamp + - Different configs get different timestamps + - No dependency on wall clock time + + The timestamp is within a reasonable range (2020-2030). + """ + # Use first 8 bytes of hash to generate timestamp + hash_int = int(config_hash[:16], 16) + # Map to range: Jan 1, 2020 to Dec 31, 2030 + min_epoch = 1577836800 # 2020-01-01 + max_epoch = 1924991999 # 2030-12-31 + return min_epoch + (hash_int % (max_epoch - min_epoch)) + + @staticmethod + def create_archiso_profile( + config: dict[str, Any], + profile_path: Path, + source_date_epoch: int + ) -> None: + """ + Generate archiso profile with deterministic settings. + + Creates: + - packages.x86_64: Sorted package list + - profiledef.sh: Build configuration + - pacman.conf: Package manager config + - airootfs/: Overlay files + """ + profile_path.mkdir(parents=True, exist_ok=True) + + # packages.x86_64 (sorted for determinism) + packages = sorted(set(config.get("packages", ["base", "linux"]))) + packages_file = profile_path / "packages.x86_64" + packages_file.write_text("\n".join(packages) + "\n") + + # profiledef.sh + profiledef = profile_path / "profiledef.sh" + iso_date = f"$(date --date=@{source_date_epoch} +%Y%m)" + iso_version = f"$(date --date=@{source_date_epoch} +%Y.%m.%d)" + + profiledef.write_text(f'''#!/usr/bin/env bash +# Deterministic archiso profile +# Generated for Debate platform + +iso_name="debate-custom" +iso_label="DEBATE_{iso_date}" +iso_publisher="Debate Platform " +iso_application="Debate Custom Linux" +iso_version="{iso_version}" +install_dir="arch" +bootmodes=('bios.syslinux.mbr' 'bios.syslinux.eltorito' 'uefi-x64.systemd-boot.esp' 'uefi-x64.systemd-boot.eltorito') +arch="x86_64" +pacman_conf="pacman.conf" +airootfs_image_type="squashfs" +airootfs_image_tool_options=('-comp' 'xz' '-Xbcj' 'x86' '-b' '1M' '-Xdict-size' '1M') + +file_permissions=( + ["/etc/shadow"]="0:0:0400" + ["/root"]="0:0:750" + ["/etc/gshadow"]="0:0:0400" +) +''') + + # pacman.conf + pacman_conf = profile_path / "pacman.conf" + pacman_conf.write_text('''[options] +Architecture = auto +CheckSpace +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist +''') + + # airootfs structure with overlay files + airootfs = profile_path / "airootfs" + airootfs.mkdir(exist_ok=True) + + for overlay in config.get("overlays", []): + for file_config in overlay.get("files", []): + file_path = airootfs / file_config["path"].lstrip("/") + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(file_config["content"]) + if "mode" in file_config: + file_path.chmod(int(file_config["mode"], 8)) +``` + +Create tests/test_deterministic.py: +```python +"""Tests for deterministic build configuration.""" + +import pytest +from backend.app.services.deterministic import DeterministicBuildConfig + + +class TestDeterministicBuildConfig: + """Test that same inputs produce same outputs.""" + + def test_hash_deterministic(self): + """Same config produces same hash.""" + config = { + "packages": ["vim", "git", "base"], + "overlays": [{ + "name": "test", + "files": [{"path": "/etc/test", "content": "hello"}] + }] + } + + hash1 = DeterministicBuildConfig.compute_config_hash(config) + hash2 = DeterministicBuildConfig.compute_config_hash(config) + + assert hash1 == hash2 + + def test_hash_order_independent(self): + """Package order doesn't affect hash.""" + config1 = {"packages": ["vim", "git", "base"], "overlays": []} + config2 = {"packages": ["base", "git", "vim"], "overlays": []} + + hash1 = DeterministicBuildConfig.compute_config_hash(config1) + hash2 = DeterministicBuildConfig.compute_config_hash(config2) + + assert hash1 == hash2 + + def test_hash_different_configs(self): + """Different configs produce different hashes.""" + config1 = {"packages": ["vim"], "overlays": []} + config2 = {"packages": ["emacs"], "overlays": []} + + hash1 = DeterministicBuildConfig.compute_config_hash(config1) + hash2 = DeterministicBuildConfig.compute_config_hash(config2) + + assert hash1 != hash2 + + def test_source_date_epoch_deterministic(self): + """Same hash produces same timestamp.""" + config_hash = "abc123def456" + + epoch1 = DeterministicBuildConfig.get_source_date_epoch(config_hash) + epoch2 = DeterministicBuildConfig.get_source_date_epoch(config_hash) + + assert epoch1 == epoch2 + + def test_source_date_epoch_in_range(self): + """Timestamp is within reasonable range.""" + config_hash = "abc123def456" + + epoch = DeterministicBuildConfig.get_source_date_epoch(config_hash) + + # Should be between 2020 and 2030 + assert 1577836800 <= epoch <= 1924991999 +``` + + +Run: +```bash +cd /home/mikkel/repos/debate +ruff check backend/app/services/deterministic.py tests/test_deterministic.py +pytest tests/test_deterministic.py -v +``` +Expected: Ruff passes, all tests pass. + + +Deterministic build config generates consistent hashes, timestamps derived from config hash. + + + + + Task 3: Create build orchestration service + +backend/app/services/build.py + + +Create backend/app/services/build.py: +```python +""" +Build orchestration service. + +Coordinates: +1. Configuration validation +2. Hash computation (for caching) +3. Sandbox creation +4. Build execution +5. Result storage +""" + +import asyncio +from pathlib import Path +from typing import Optional +from uuid import uuid4 +from datetime import datetime, UTC + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.config import settings +from app.db.models.build import Build, BuildStatus +from app.services.sandbox import BuildSandbox +from app.services.deterministic import DeterministicBuildConfig + + +class BuildService: + """Orchestrates ISO build process.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.sandbox = BuildSandbox() + self.output_root = Path(settings.iso_output_root) + + async def get_or_create_build( + self, + config: dict + ) -> tuple[Build, bool]: + """ + Get existing build from cache or create new one. + + Returns: + Tuple of (Build, is_cached) + """ + # Compute deterministic hash + config_hash = DeterministicBuildConfig.compute_config_hash(config) + + # Check cache + stmt = select(Build).where( + Build.config_hash == config_hash, + Build.status == BuildStatus.completed + ) + result = await self.db.execute(stmt) + cached_build = result.scalar_one_or_none() + + if cached_build: + # Return cached build + return cached_build, True + + # Create new build + build = Build( + id=uuid4(), + config_hash=config_hash, + status=BuildStatus.pending + ) + self.db.add(build) + await self.db.commit() + await self.db.refresh(build) + + return build, False + + async def execute_build( + self, + build: Build, + config: dict + ) -> Build: + """ + Execute the actual ISO build. + + Process: + 1. Update status to building + 2. Create sandbox container + 3. Generate archiso profile + 4. Run build + 5. Update status with result + """ + build.status = BuildStatus.building + build.started_at = datetime.now(UTC) + await self.db.commit() + + container_path = None + profile_path = self.output_root / str(build.id) / "profile" + output_path = self.output_root / str(build.id) / "output" + + try: + # Create sandbox + container_path = await self.sandbox.create_build_container(str(build.id)) + + # Generate deterministic profile + source_date_epoch = DeterministicBuildConfig.get_source_date_epoch( + build.config_hash + ) + DeterministicBuildConfig.create_archiso_profile( + config, profile_path, source_date_epoch + ) + + # Run build in sandbox + return_code, stdout, stderr = await self.sandbox.run_build( + container_path, profile_path, output_path, source_date_epoch + ) + + if return_code == 0: + # Find generated ISO + iso_files = list(output_path.glob("*.iso")) + if iso_files: + build.iso_path = str(iso_files[0]) + build.status = BuildStatus.completed + else: + build.status = BuildStatus.failed + build.error_message = "Build completed but no ISO found" + else: + build.status = BuildStatus.failed + build.error_message = stderr or f"Build failed with code {return_code}" + + build.build_log = stdout + "\n" + stderr + + except Exception as e: + build.status = BuildStatus.failed + build.error_message = str(e) + + finally: + # Cleanup sandbox + if container_path: + await self.sandbox.cleanup_container(container_path) + + build.completed_at = datetime.now(UTC) + await self.db.commit() + await self.db.refresh(build) + + return build + + async def get_build_status(self, build_id: str) -> Optional[Build]: + """Get build by ID.""" + stmt = select(Build).where(Build.id == build_id) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() +``` + + +Run: +```bash +cd /home/mikkel/repos/debate +ruff check backend/app/services/build.py +python -c "from backend.app.services.build import BuildService; print('Import OK')" +``` +Expected: No ruff errors, import succeeds. + + +Build service coordinates hash computation, caching, sandbox execution, and status tracking. + + + + + + +1. `ruff check backend/app/services/` passes +2. `pytest tests/test_deterministic.py` - all tests pass +3. Sandbox service can be imported without errors +4. Build service can be imported without errors +5. DeterministicBuildConfig.compute_config_hash produces consistent results + + + +- Sandbox service creates isolated systemd-nspawn containers (ISO-04) +- Builds run with --private-network (no network access) +- SOURCE_DATE_EPOCH set for deterministic builds +- Same configuration produces identical hash +- Build service coordinates full build lifecycle +- Cache lookup happens before build execution + + + +After completion, create `.planning/phases/01-core-infrastructure-security/01-05-SUMMARY.md` +