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
+
+
+
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
+
+
+
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
+
+
+
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
+
+
+
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
+
+
+