diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..4fe2d1356 --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# ---------------------------- +# Backend (FastAPI) - runtime +# ---------------------------- +# Used by backend OAuth + GitHub sync on startup (GitHub OAuth endpoints). +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret + +# Used to sign/verify JWT access tokens issued by the API. +# WARNING: Replace this with a long, random secret (>= 32 chars / 256 bits). +# Example only (DO NOT use in production). +JWT_SECRET=unsafe-example-jwt-secret-please-change + +# Database URL for SQLAlchemy async engine. +# Examples: +# Postgres: +# postgresql+asyncpg://USER:PASSWORD@HOST:5432/DB +# SQLite: +# sqlite+aiosqlite:///./local.db +# +# Used by: +# - `docker-compose.yml` via `DATABASE_URL` +# - `deploy.yml` database migration job (`alembic upgrade head`) +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/solfoundry + +# Redis connection string. +# Used by: +# - `backend/app/services/websocket_manager.py` (Redis pub/sub for websockets) +REDIS_URL=redis://localhost:6379/0 + +# ---------------------------- +# Frontend (Vite) - build-time hints +# ---------------------------- +# Used by some pages to construct absolute API URLs. +# Many API calls use relative paths (`/api/...`), so this may be optional for some deployments. +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 + +# ---------------------------- +# Docker Compose (local) - DB +# ---------------------------- +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=solfoundry + +# ---------------------------- +# CI/CD (GitHub Actions) - deployment secrets +# ---------------------------- +# These values must be set as GitHub repository secrets. + +# deploy.yml -> Vercel +VERCEL_TOKEN=your_vercel_token +VERCEL_ORG_ID=your_vercel_org_id +VERCEL_PROJECT_ID=your_vercel_project_id + +# deploy.yml -> DigitalOcean Kubernetes +DIGITALOCEAN_ACCESS_TOKEN=your_digitalocean_access_token +DIGITALOCEAN_CLUSTER_NAME=your_digitalocean_cluster_name + +# deploy.yml -> frontend build injection +# Used by the `deploy.yml` frontend build step (currently wired into the build environment). +API_URL=https://api.solfoundry.org \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4362a3af..fe9cacc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,11 +256,87 @@ jobs: run: cargo fmt -- --check continue-on-error: true + # ==================== CONTAINER IMAGE BUILD CHECKS ==================== + container-image-build: + name: Container Image Build (backend + frontend) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build backend image + run: docker build -t solfoundry-backend:test ./backend + + - name: Build frontend image + run: docker build -t solfoundry-frontend:test ./frontend + + # ==================== DOCKER COMPOSE SMOKE TEST ==================== + # Spins up the full local stack and verifies basic readiness. + compose-smoke-test: + name: Compose Smoke Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Start stack (db + redis + backend + frontend) + env: + JWT_SECRET: smoke-test-jwt-secret-at-least-32-characters + GITHUB_CLIENT_ID: smoke-test-client-id + GITHUB_CLIENT_SECRET: smoke-test-client-secret + run: | + docker compose up -d --build db redis backend frontend + + - name: Wait for health endpoints + verify DB/Redis/proxy/auth paths + run: | + set -euo pipefail + for i in {1..30}; do + if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then + break + fi + sleep 2 + done + + BACKEND_HEALTH="$(curl -fsS http://localhost:8000/health)" + echo "$BACKEND_HEALTH" + BACKEND_HEALTH="$BACKEND_HEALTH" python - <<'PY' +import json, os +health = json.loads(os.environ["BACKEND_HEALTH"]) +assert health.get("database") == "ok", "database is not healthy" +assert health.get("redis") == "ok", "redis is not healthy" +assert health.get("status") == "ok", "overall backend health is not ok" +print("backend health verified (database + redis)") +PY + + curl -fsS http://localhost:8081/health + # Verify that nginx -> backend proxying works. + curl -fsS "http://localhost:8081/api/leaderboard?limit=1" >/dev/null + + # Minimal authenticated path exercise: protected endpoint should reject unauthenticated requests. + CODE="$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/auth/me)" + test "$CODE" = "401" + + - name: Exercise backend schema initialization + run: | + docker compose exec -T backend python -c "import asyncio; from app.database import init_db; asyncio.run(init_db())" + + - name: Ensure compose services are healthy + run: | + set -euo pipefail + docker inspect --format '{{json .State.Health.Status}}' solfoundry-db | rg '"healthy"' + docker inspect --format '{{json .State.Health.Status}}' solfoundry-redis | rg '"healthy"' + docker inspect --format '{{json .State.Health.Status}}' solfoundry-backend | rg '"healthy"' + docker inspect --format '{{json .State.Health.Status}}' solfoundry-frontend | rg '"healthy"' + + - name: Tear down + if: always() + run: docker compose down -v + # ==================== SUMMARY ==================== ci-status: name: CI Status Summary runs-on: ubuntu-latest - needs: [backend-lint, backend-tests, frontend-lint, frontend-typecheck, frontend-tests, frontend-build, contracts-check, rust-lint] + needs: [backend-lint, backend-tests, frontend-lint, frontend-typecheck, frontend-tests, frontend-build, contracts-check, rust-lint, container-image-build, compose-smoke-test, backend-integration-postgres] if: always() steps: - name: Check CI Results @@ -277,7 +353,74 @@ jobs: echo "| Frontend Build | ${{ needs.frontend-build.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Contracts Check | ${{ needs.contracts-check.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Rust Lint | ${{ needs.rust-lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Container Image Build | ${{ needs.container-image-build.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Compose Smoke Test | ${{ needs.compose-smoke-test.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Backend Integration (Postgres) | ${{ needs.backend-integration-postgres.result }} |" >> $GITHUB_STEP_SUMMARY - name: Fail if any job failed if: contains(needs.*.result, 'failure') - run: exit 1 \ No newline at end of file + run: exit 1 + + # ==================== BACKEND POSTGRES INTEGRATION ==================== + backend-integration-postgres: + name: Backend Integration (Postgres) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + options: >- + --health-cmd="pg_isready -U test -d test" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + env: + PYTHONPATH: . + TEST_DATABASE_URL: postgresql+asyncpg://test:test@postgres:5432/test + SECRET_KEY: test-secret-key-for-ci + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Dependencies + working-directory: backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio + + - name: Wait for Postgres + run: | + for i in {1..30}; do + if python - <<'PY' +import os, sys, time +import asyncpg, asyncio + +async def main(): + url = os.environ["TEST_DATABASE_URL"] + try: + conn = await asyncpg.connect(url) + await conn.close() + return True + except Exception: + return False + +print(asyncio.run(main())) +PY + then + break + fi + sleep 2 + done + + - name: Run DB-backed health test + working-directory: backend + run: pytest tests/test_logging_and_errors.py::test_health_check_format -v \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4cdadfc54..4669d20e0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,11 +11,16 @@ on: environment: description: 'Deployment environment' required: true - default: 'production' + default: 'staging' type: choice options: - production - staging + image_tag: + description: 'Specific backend image tag / git sha to deploy (rollback). Leave empty for latest.' + required: false + default: '' + type: string concurrency: group: deploy-${{ github.ref }} @@ -25,13 +30,19 @@ env: PYTHON_VERSION: '3.11' NODE_VERSION: '20' REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}/backend + BACKEND_IMAGE: ${{ github.repository }}/backend + FRONTEND_IMAGE: ${{ github.repository }}/frontend + # Push to main auto-deploys staging; production is manual via workflow_dispatch. + DEPLOY_ENV: ${{ github.event_name == 'push' && 'staging' || inputs.environment }} + # Rollback uses an immutable image tag and is independent from source checkout. + BACKEND_IMAGE_TAG: ${{ inputs.image_tag || github.sha }} jobs: # ==================== BUILD FRONTEND ==================== build-frontend: name: Build Frontend runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' || inputs.image_tag == '' outputs: has_frontend: ${{ steps.check.outputs.has_frontend }} steps: @@ -67,27 +78,32 @@ jobs: run: npm run build env: NODE_ENV: production - NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }} + VITE_API_URL: ${{ secrets.API_URL }} - name: Upload Build Artifacts if: steps.check.outputs.has_frontend == 'true' uses: actions/upload-artifact@v4 with: name: frontend-build - path: frontend/.next/ + path: frontend/dist/ retention-days: 7 # ==================== DEPLOY FRONTEND TO VERCEL ==================== deploy-frontend: name: Deploy Frontend (Vercel) runs-on: ubuntu-latest + environment: ${{ env.DEPLOY_ENV }} needs: build-frontend - if: needs.build-frontend.outputs.has_frontend == 'true' && github.event_name != 'workflow_dispatch' + if: (github.event_name != 'workflow_dispatch' || inputs.image_tag == '') && needs.build-frontend.outputs.has_frontend == 'true' + outputs: + vercel_preview_url: ${{ steps.vercel_deploy_preview.outputs['preview-url'] }} steps: - name: Checkout uses: actions/checkout@v4 - - name: Deploy to Vercel + - name: Deploy to Vercel (production) + id: vercel_deploy + if: env.DEPLOY_ENV == 'production' uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} @@ -96,15 +112,47 @@ jobs: vercel-args: '--prod' working-directory: frontend - # ==================== BUILD BACKEND DOCKER IMAGE ==================== - build-backend: - name: Build Backend Docker Image + - name: Deploy to Vercel (staging/preview) + id: vercel_deploy_preview + if: env.DEPLOY_ENV != 'production' + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + # No --prod flag => preview deployment; action exposes `preview-url`. + working-directory: frontend + + - name: Publish staging URL + if: env.DEPLOY_ENV != 'production' + run: | + echo "- [Staging Site](${{ steps.vercel_deploy_preview.outputs['preview-url'] }})" >> $GITHUB_STEP_SUMMARY + + - name: Upload staging URL artifact + if: env.DEPLOY_ENV != 'production' + run: | + echo "${{ steps.vercel_deploy_preview.outputs['preview-url'] }}" > staging-url.txt + cat staging-url.txt >> $GITHUB_STEP_SUMMARY + shell: bash + + - name: Upload staging URL artifact (file) + if: env.DEPLOY_ENV != 'production' + uses: actions/upload-artifact@v4 + with: + name: staging-url + path: staging-url.txt + retention-days: 7 + + # ==================== BUILD & PUSH CONTAINER IMAGES ==================== + build-and-push-images: + name: Build and Push Images (GHCR) runs-on: ubuntu-latest permissions: contents: read packages: write outputs: - image_tag: ${{ steps.meta.outputs.tags }} + backend_image_tag: ${{ steps.tags.outputs.backend_image_tag }} + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.image_tag == '') steps: - name: Checkout uses: actions/checkout@v4 @@ -119,34 +167,110 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract Metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,prefix= - type=ref,event=branch - type=semver,pattern={{version}} + - name: Compute image tags + id: tags + run: | + BACKEND_TAGS="${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.ref_name }}" + FRONTEND_TAGS="${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ github.ref_name }}" + if [ "${{ env.DEPLOY_ENV }}" = "production" ]; then + BACKEND_TAGS="$BACKEND_TAGS,${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest" + FRONTEND_TAGS="$FRONTEND_TAGS,${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest" + else + BACKEND_TAGS="$BACKEND_TAGS,${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:staging" + FRONTEND_TAGS="$FRONTEND_TAGS,${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:staging" + fi + echo "backend_tags=$BACKEND_TAGS" >> $GITHUB_OUTPUT + echo "frontend_tags=$FRONTEND_TAGS" >> $GITHUB_OUTPUT + echo "backend_image_tag=${{ github.sha }}" >> $GITHUB_OUTPUT - - name: Build and Push Docker Image + - name: Build and Push Backend Image uses: docker/build-push-action@v5 with: context: ./backend push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.tags.outputs.backend_tags }} cache-from: type=gha cache-to: type=gha,mode=max file: ./backend/Dockerfile - continue-on-error: true + + - name: Build and Push Frontend Image + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: ${{ steps.tags.outputs.frontend_tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + file: ./frontend/Dockerfile + + # ==================== DEPLOY REDIS TO DIGITALOCEAN ==================== + deploy-redis: + name: Deploy Redis (DigitalOcean) + runs-on: ubuntu-latest + environment: ${{ env.DEPLOY_ENV }} + needs: build-and-push-images + if: always() + steps: + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + + - name: Save DigitalOcean kubeconfig + run: doctl kubernetes cluster kubeconfig save ${{ secrets.DIGITALOCEAN_CLUSTER_NAME }} + + - name: Apply Redis resources + run: | + kubectl apply --namespace ${{ env.DEPLOY_ENV }} -f - <<'EOF' + apiVersion: apps/v1 + kind: Deployment + metadata: + name: solfoundry-redis + spec: + replicas: 1 + selector: + matchLabels: + app: solfoundry-redis + template: + metadata: + labels: + app: solfoundry-redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + readinessProbe: + tcpSocket: + port: 6379 + initialDelaySeconds: 5 + periodSeconds: 10 + --- + apiVersion: v1 + kind: Service + metadata: + name: redis + spec: + selector: + app: solfoundry-redis + ports: + - protocol: TCP + port: 6379 + targetPort: 6379 + EOF + + - name: Wait for Redis rollout + run: | + kubectl rollout status deployment/solfoundry-redis --namespace ${{ env.DEPLOY_ENV }} --timeout=300s # ==================== DEPLOY BACKEND TO DIGITALOCEAN ==================== deploy-backend: name: Deploy Backend (DigitalOcean) runs-on: ubuntu-latest - needs: build-backend - if: github.event_name != 'workflow_dispatch' + environment: ${{ env.DEPLOY_ENV }} + needs: [build-and-push-images, deploy-redis] + if: always() steps: - name: Checkout uses: actions/checkout@v4 @@ -163,21 +287,21 @@ jobs: run: | # Update deployment image kubectl set image deployment/solfoundry-backend \ - backend=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \ - --namespace production + backend=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ env.BACKEND_IMAGE_TAG }} \ + --namespace ${{ env.DEPLOY_ENV }} # Wait for rollout kubectl rollout status deployment/solfoundry-backend \ - --namespace production \ + --namespace ${{ env.DEPLOY_ENV }} \ --timeout=300s - continue-on-error: true # ==================== DATABASE MIGRATIONS ==================== migrate-database: name: Run Database Migrations runs-on: ubuntu-latest + environment: ${{ env.DEPLOY_ENV }} needs: deploy-backend - if: github.event_name != 'workflow_dispatch' + if: env.DEPLOY_ENV == 'production' && (github.event_name != 'workflow_dispatch' || inputs.image_tag == '') steps: - name: Checkout uses: actions/checkout@v4 @@ -198,34 +322,95 @@ jobs: run: alembic upgrade head env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - continue-on-error: true + + # ==================== DEPLOYMENT VERIFICATION ==================== + # Verify backend readiness + frontend health after rollout. + verify-deployment: + name: Verify Deployment (health checks) + runs-on: ubuntu-latest + needs: [deploy-frontend, deploy-backend, migrate-database] + if: always() + environment: ${{ env.DEPLOY_ENV }} + steps: + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + + - name: Save DigitalOcean kubeconfig + run: doctl kubernetes cluster kubeconfig save ${{ secrets.DIGITALOCEAN_CLUSTER_NAME }} + + - name: Verify backend /health via port-forward + env: + KUBE_NAMESPACE: ${{ env.DEPLOY_ENV }} + run: | + set -euo pipefail + kubectl port-forward deployment/solfoundry-backend 18000:8000 --namespace "$KUBE_NAMESPACE" >/tmp/port-forward.log 2>&1 & + PF_PID=$! + trap 'kill "$PF_PID" >/dev/null 2>&1 || true' EXIT + for i in {1..30}; do + if curl -fsS http://localhost:18000/health >/dev/null 2>&1; then + break + fi + sleep 2 + done + HEALTH_JSON="$(curl -fsS http://localhost:18000/health)" + echo "$HEALTH_JSON" + HEALTH_JSON="$HEALTH_JSON" python - <<'PY' +import json, os +health = json.loads(os.environ["HEALTH_JSON"]) +if health.get("database") != "ok": + raise SystemExit("backend database connectivity check failed") +if health.get("redis") != "ok": + raise SystemExit("backend redis connectivity check failed") +print("backend health verified (database + redis)") +PY + + - name: Verify frontend /health endpoint + if: github.event_name != 'workflow_dispatch' || inputs.image_tag == '' + run: | + set -euo pipefail + if [ "${{ env.DEPLOY_ENV }}" = "production" ]; then + curl -fsS https://solfoundry.org/health | head -n 1 + else + curl -fsS "${{ needs.deploy-frontend.outputs.vercel_preview_url }}/health" | head -n 1 + fi # ==================== DEPLOYMENT SUMMARY ==================== deploy-status: name: Deployment Status runs-on: ubuntu-latest - needs: [build-frontend, deploy-frontend, build-backend, deploy-backend, migrate-database] + needs: [build-frontend, deploy-frontend, build-and-push-images, deploy-redis, deploy-backend, migrate-database, verify-deployment] if: always() steps: - name: Generate Deployment Summary run: | echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** Production" >> $GITHUB_STEP_SUMMARY - echo "**Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "**Environment:** ${{ env.DEPLOY_ENV }}" >> $GITHUB_STEP_SUMMARY + echo "**Backend Image Tag:** ${{ env.BACKEND_IMAGE_TAG }}" >> $GITHUB_STEP_SUMMARY echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Component | Status |" >> $GITHUB_STEP_SUMMARY echo "|-----------|--------|" >> $GITHUB_STEP_SUMMARY echo "| Frontend Build | ${{ needs.build-frontend.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Frontend Deploy | ${{ needs.deploy-frontend.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Backend Build | ${{ needs.build-backend.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build & Push Images | ${{ needs.build-and-push-images.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Redis Deploy | ${{ needs.deploy-redis.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Backend Deploy | ${{ needs.deploy-backend.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Database Migration | ${{ needs.migrate-database.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Post-deploy Verification | ${{ needs.verify-deployment.result }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Links" >> $GITHUB_STEP_SUMMARY - echo "- [Production Site](https://solfoundry.org)" >> $GITHUB_STEP_SUMMARY - echo "- [API Health](https://api.solfoundry.org/health)" >> $GITHUB_STEP_SUMMARY + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.image_tag }}" != "" ]; then + echo "- Backend rollback/tag deployment executed (frontend unchanged)." >> $GITHUB_STEP_SUMMARY + elif [ "${{ env.DEPLOY_ENV }}" = "production" ]; then + echo "- [Production Site](https://solfoundry.org)" >> $GITHUB_STEP_SUMMARY + echo "- [API Health](https://api.solfoundry.org/health)" >> $GITHUB_STEP_SUMMARY + else + echo "- [Staging Site](${{ needs.deploy-frontend.outputs.vercel_preview_url }})" >> $GITHUB_STEP_SUMMARY + echo "- [Staging /health](${{ needs.deploy-frontend.outputs.vercel_preview_url }}/health)" >> $GITHUB_STEP_SUMMARY + fi - name: Notify on Success if: success() diff --git a/.gitignore b/.gitignore index 67dccd23b..2f9977e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Environment & Secrets .env -.env.* *.env +.env.* +!.env.example *.key *.pem treasury_key.json diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 000000000..a149004f3 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +# Database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=solfoundry +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/solfoundry + +# Cache +REDIS_URL=redis://localhost:6379/0 + +# Auth +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret +# WARNING: Replace this with a long, random secret (>= 32 chars / 256 bits). +# Example only (DO NOT use in production). +JWT_SECRET=unsafe-example-jwt-secret-please-change + +# CORS +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..9fb6733c9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,60 @@ +# Stage 1: Build dependencies +FROM python:3.12-slim AS builder + +WORKDIR /app + +# Install system dependencies for building +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install python dependencies into a virtualenv +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Stage 2: Runtime +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# Create a non-root user +RUN groupadd -g 10001 appgroup && \ + useradd -u 10001 -g appgroup -s /bin/sh -m appuser + +# Install runtime system dependencies (libpq for postgres) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy virtualenv from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy application code with correct ownership +COPY --chown=appuser:appgroup . . + +# Create logs directory with restricted permissions +RUN mkdir -p logs && chown -R appuser:appgroup logs && chmod 755 logs + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8000 + +# Environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV LOG_DIR=/app/logs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the app +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/main.py b/backend/app/main.py index b590a1e85..869f6750d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,11 +2,13 @@ import asyncio import logging +import os from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware +import redis.asyncio as redis from starlette.exceptions import HTTPException as StarletteHTTPException from app.core.logging_config import setup_logging @@ -249,10 +251,21 @@ async def health_check(): logger.error("Health check DB failure: %s", e) db_status = "error" + redis_status = "ok" + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + try: + redis_client = redis.from_url(redis_url) + await redis_client.ping() + await redis_client.close() + except Exception as e: + logger.error("Health check Redis failure: %s", e) + redis_status = "error" + last_sync = get_last_sync() return { - "status": "ok" if db_status == "ok" else "degraded", + "status": "ok" if db_status == "ok" and redis_status == "ok" else "degraded", "database": db_status, + "redis": redis_status, "bounties": len(_bounty_store), "contributors": len(_store), "last_sync": last_sync.isoformat() if last_sync else None, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7024cdb44..e1f26992e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -8,8 +8,12 @@ import pytest # Set test database URL before importing app modules -# This must be done before any app imports -os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +# This must be done before any app imports. +# +# Default behavior (unit tests): use in-memory SQLite. +# Integration behavior (CI): if `TEST_DATABASE_URL` is set, use it instead +# so the app + ORM exercise a real backend (e.g. Postgres). +os.environ["DATABASE_URL"] = os.getenv("TEST_DATABASE_URL", "sqlite+aiosqlite:///:memory:") os.environ["SECRET_KEY"] = "test-secret-key-for-ci" # Configure asyncio mode for pytest diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..49b871410 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +services: + db: + image: postgres:16-alpine + container_name: solfoundry-db + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-solfoundry} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-solfoundry}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: solfoundry-redis + restart: always + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: solfoundry-backend + restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-solfoundry} + REDIS_URL: redis://redis:6379/0 + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} + JWT_SECRET: ${JWT_SECRET} + ports: + - "8000:8000" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: solfoundry-frontend + restart: always + depends_on: + backend: + condition: service_healthy + ports: + - "8081:8080" + +volumes: + postgres_data: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 000000000..5e92c9150 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Port configuration +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 000000000..865725057 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +# Stage 1: Build +FROM node:20-slim AS builder + +WORKDIR /app + +# Install dependencies using npm ci for reproducible builds +COPY package*.json ./ +RUN npm ci + +# Copy source and build +COPY . . +RUN npm run build + +# Stage 2: Serve +FROM nginxinc/nginx-unprivileged:alpine + +# Copy build artifacts +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port (Nginx unprivileged uses 8080 by default internally) +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:8080/health || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 000000000..bf065bfcd --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,55 @@ +server { + listen 8080; + server_name localhost; + + # Security headers (defense in depth; CSP should match the app's needs) + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + + # Root directory for static files + root /usr/share/nginx/html; + index index.html; + + # SPA routing - redirect all to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # API Proxy + location /api { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts to improve resilience under slow/unresponsive upstreams + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + send_timeout 60s; + } + + # Internal health check for container + location /health { + access_log off; + return 200 'ok'; + } + + # Cache Control for static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 30d; + add_header Cache-Control "public, no-transform"; + } + + # Error pages + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/frontend/public/health b/frontend/public/health new file mode 100644 index 000000000..4f25bafb1 --- /dev/null +++ b/frontend/public/health @@ -0,0 +1,2 @@ +ok +