add CI/CD pipeline with blue-green deployment

GitLab CI runs pytest + ruff on master/MRs, then auto-deploys via SSH.
Blue-green strategy using Docker Compose profiles with an nginx router
on port 5000 for zero-downtime switching between slots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-13 14:39:15 +01:00
parent 3dbdd17ddb
commit fa09fc81c9
6 changed files with 257 additions and 0 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
CLAUDE.md
.bedrockapikey
.live-slot

32
.gitlab-ci.yml Normal file
View File

@@ -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"

View File

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

76
deploy.sh Executable file
View File

@@ -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" <<NGINX
upstream app {
server ${TARGET}-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;
}
}
NGINX
# Ensure router is running, then reload
$COMPOSE up -d router
$COMPOSE exec router nginx -s reload
# ── Stop old slot ───────────────────────────────────────────
if [ "$CURRENT" != "none" ]; then
echo "==> Stopping $CURRENT..."
$COMPOSE --profile "$CURRENT" stop
fi
# ── Record live slot ────────────────────────────────────────
echo "$TARGET" > "$LIVE_FILE"
echo "==> Deployed $TARGET successfully!"

128
docker-compose.prod.yml Normal file
View File

@@ -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:

15
router/default.conf Normal file
View File

@@ -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;
}
}