diff --git a/.gitignore b/.gitignore index 215a4ca..3ed7dbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ CLAUDE.md .bedrockapikey +.live-slot diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..52349d0 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,32 @@ +stages: + - test + - deploy + +test: + stage: test + image: python:3.12-slim + before_script: + - pip install uv + script: + - cd padelnomics && uv sync + - uv run pytest tests/ -x -q + - uv run ruff check src/ tests/ + rules: + - if: $CI_COMMIT_BRANCH == "master" + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +deploy: + stage: deploy + image: alpine:latest + needs: [test] + rules: + - if: $CI_COMMIT_BRANCH == "master" + before_script: + - apk add --no-cache openssh-client + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts + script: + - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/padelnomics && git pull origin master && ./deploy.sh" diff --git a/CHANGELOG.md b/CHANGELOG.md index 050fb60..3ce2af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `tests/test_billing_routes.py` — route tests (pricing, checkout, manage, cancel, resume, subscription_required decorator) - Added `hypothesis>=6.100.0` and `respx>=0.22.0` to dev dependencies for property-based testing and httpx mocking - **Factored into Copier template** — all billing tests now generate as `.jinja` templates with provider-specific conditionals for Stripe, Paddle, and LemonSqueezy +- GitLab CI/CD pipeline (`.gitlab-ci.yml`) — runs pytest + ruff on master/MRs, auto-deploys on master +- Blue-green deployment with Docker Compose profiles (`docker-compose.prod.yml`, `deploy.sh`) + - nginx router on port 5000 proxies to active blue/green slot + - Zero-downtime: new slot health-checked before traffic switch + - Automatic rollback on failed health check ### Changed - `planner.js` no longer contains `calc()`, `pmt()`, or `calcIRR()` functions — computation moved server-side diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..f41de0d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE="docker compose -f docker-compose.prod.yml" +LIVE_FILE=".live-slot" +ROUTER_CONF="router/default.conf" + +# ── Determine slots ───────────────────────────────────────── + +CURRENT=$(cat "$LIVE_FILE" 2>/dev/null || echo "none") + +if [ "$CURRENT" = "blue" ]; then + TARGET="green" +else + TARGET="blue" +fi + +echo "==> Current: $CURRENT → Deploying: $TARGET" + +# ── Build ─────────────────────────────────────────────────── + +echo "==> Building $TARGET..." +$COMPOSE --profile "$TARGET" build + +# ── Migrate ───────────────────────────────────────────────── + +echo "==> Running migrations..." +$COMPOSE --profile "$TARGET" run --rm "${TARGET}-app" \ + python -m padelnomics.migrations.migrate + +# ── Start & health check ─────────────────────────────────── + +echo "==> Starting $TARGET (waiting for health check)..." +if ! $COMPOSE --profile "$TARGET" up -d --wait; then + echo "!!! Health check failed — rolling back" + $COMPOSE --profile "$TARGET" stop + exit 1 +fi + +# ── Switch router ─────────────────────────────────────────── + +echo "==> Switching router to $TARGET..." +mkdir -p "$(dirname "$ROUTER_CONF")" +cat > "$ROUTER_CONF" < Stopping $CURRENT..." + $COMPOSE --profile "$CURRENT" stop +fi + +# ── Record live slot ──────────────────────────────────────── + +echo "$TARGET" > "$LIVE_FILE" +echo "==> Deployed $TARGET successfully!" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..8e06f5b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,128 @@ +services: + # ── Always-on infrastructure ────────────────────────────── + + router: + image: nginx:alpine + restart: unless-stopped + ports: + - "5000:80" + volumes: + - ./router/default.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - net + healthcheck: + test: ["CMD", "nginx", "-t"] + interval: 30s + timeout: 5s + + litestream: + image: litestream/litestream:latest + restart: unless-stopped + command: replicate -config /etc/litestream.yml + volumes: + - app-data:/app/data + - ./padelnomics/litestream.yml:/etc/litestream.yml:ro + + # ── Blue slot ───────────────────────────────────────────── + + blue-app: + profiles: ["blue"] + build: + context: ./padelnomics + restart: unless-stopped + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + blue-worker: + profiles: ["blue"] + build: + context: ./padelnomics + restart: unless-stopped + command: python -m padelnomics.worker + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + + blue-scheduler: + profiles: ["blue"] + build: + context: ./padelnomics + restart: unless-stopped + command: python -m padelnomics.worker scheduler + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + + # ── Green slot ──────────────────────────────────────────── + + green-app: + profiles: ["green"] + build: + context: ./padelnomics + restart: unless-stopped + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + green-worker: + profiles: ["green"] + build: + context: ./padelnomics + restart: unless-stopped + command: python -m padelnomics.worker + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + + green-scheduler: + profiles: ["green"] + build: + context: ./padelnomics + restart: unless-stopped + command: python -m padelnomics.worker scheduler + env_file: .env + environment: + - DATABASE_PATH=/app/data/app.db + volumes: + - app-data:/app/data + networks: + - net + +volumes: + app-data: + +networks: + net: diff --git a/router/default.conf b/router/default.conf new file mode 100644 index 0000000..cb78a15 --- /dev/null +++ b/router/default.conf @@ -0,0 +1,15 @@ +upstream app { + server blue-app:5000; +} + +server { + listen 80; + + location / { + proxy_pass http://app; + 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; + } +}