diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..9e77073f --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,86 @@ +name: Deploy Staging + +on: + push: + branches: + - main + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: staging + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 # needed to get previous tag for rollback + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,format=long + + # In a real scenario, this would authenticate to a registry + # For Phase 0, we just build the image locally and tag it with the SHA + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + load: true # load into local docker daemon + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # We simulate the staging deployment locally on the GitHub Runner + # since there's no remote target specified yet. + - name: Deploy to Staging (Runner) + env: + DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL || 'postgresql://learnault:staging_password@localhost:5432/learnault_staging?schema=public' }} + JWT_SECRET: ${{ secrets.STAGING_JWT_SECRET || 'fallback-staging-secret' }} + run: | + echo "Actor: ${{ github.actor }}" + echo "Digest/Tag: ${{ steps.meta.outputs.version }}" + + # We need to run the postgres db as a mock for staging + docker compose -f docker-compose.staging.yml up -d db + sleep 10 # Wait for db to initialize + + # Run deployment + ./scripts/deploy-staging.sh ${{ steps.meta.outputs.version }} + + - name: Run Smoke Tests + run: ./scripts/smoke-test.sh + + - name: Handle Failure & Rollback + if: failure() + run: | + echo "Deployment or Smoke Tests failed. Initiating Rollback..." + + # Get the previous commit SHA to rollback to + PREV_SHA=$(git rev-parse HEAD^) + PREV_TAG="sha-$PREV_SHA" + + echo "Rolling back to tag: $PREV_TAG" + + # Since this is a CI mock, we just run the rollback script + # In reality, this tag would be pulled from the registry + # We'll just build it to simulate + docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV_TAG . + + ./scripts/rollback-staging.sh ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV_TAG diff --git a/config/staging.env.example b/config/staging.env.example new file mode 100644 index 00000000..ed2a2d49 --- /dev/null +++ b/config/staging.env.example @@ -0,0 +1,13 @@ +# Staging Environment Configuration Contract +# This file documents the required environment variables for the staging deployment. +# Values are injected by the CI/CD pipeline. + +PORT=5000 +NODE_ENV=staging +STELLAR_NETWORK=testnet + +# The connection string for the staging database +DATABASE_URL= + +# Secret for signing JWTs +JWT_SECRET= diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 00000000..0b792331 --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,53 @@ +# Staging Deployment Runbook + +This document details the processes and configurations for the Learnault API Staging Environment. + +## Overview + +The staging environment is deployed using an immutable Docker image digest. The deployment automates configuration validation, database migrations, readiness checks, smoke tests, and graceful rollbacks. + +## Deployment Pipeline + +1. **Build & Tag:** A new Docker image is built for every commit to `main` using a multi-stage `Dockerfile`. +2. **Predeploy Checks:** The `deploy-staging.sh` script validates the environment configuration (`config/staging.env.example`). +3. **Migrations:** Prisma migrations are applied *before* the application starts using the newly built image (`npx prisma migrate deploy`). +4. **Deploy & Await:** The API container is deployed via Docker Compose and the script polls `/health` until readiness is confirmed. +5. **Smoke Tests:** `smoke-test.sh` runs a suite of safe, read-only API tests (e.g., `/health`) to verify operational sanity. +6. **Rollback:** If any step fails, the `rollback-staging.sh` script is triggered automatically to revert the API container to the *previous* immutable image digest. + +## Migration-Forward Policy + +**CRITICAL: We never rollback the database.** + +In the event of a failed deployment that included a bad database migration: +1. The rollback script *only* reverts the API container to the previous image. +2. Because the schema cannot be safely rolled back in PostgreSQL without risking data loss, **the previous API version must be backward compatible with the new schema**, or the environment will remain broken. +3. If the environment is broken, the engineering team must immediately write a *forward migration* (a new PR) to fix the schema or drop the problematic changes safely. + +### How to apply a fix: +1. Create a new branch. +2. Fix the broken logic or write a new Prisma migration (`npx prisma migrate dev --name fix_schema`). +3. Merge the PR. The pipeline will automatically build a new image, run the new migration, and deploy. + +## Configuration (Secrets Contract) + +See `config/staging.env.example` for the list of required environment variables. These are typically stored in GitHub Secrets and injected during the CI/CD pipeline. + +## Manual Execution + +To rehearse the deployment locally: +```bash +# 1. Build an image +docker build -t learnault-api:test-tag . + +# 2. Deploy +export DATABASE_URL="postgresql://user:pass@localhost:5432/db" +export JWT_SECRET="secret" +./scripts/deploy-staging.sh test-tag + +# 3. Smoke Test +./scripts/smoke-test.sh + +# 4. Rollback (Requires a previously built tag) +./scripts/rollback-staging.sh old-tag +``` diff --git a/scripts/deploy-staging.sh b/scripts/deploy-staging.sh new file mode 100755 index 00000000..756e0d86 --- /dev/null +++ b/scripts/deploy-staging.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -e + +echo "Starting Staging Deployment..." + +# Validate configuration +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL is not set." + exit 1 +fi + +if [ -z "$JWT_SECRET" ]; then + echo "Error: JWT_SECRET is not set." + exit 1 +fi + +IMAGE_TAG=${1:-staging} +echo "Deploying image learnault-api:$IMAGE_TAG" + +# Run safe Prisma migrations +# Use the same image to ensure Prisma CLI matches the schema +echo "Running database migrations..." +docker run --rm \ + --network host \ + -e DATABASE_URL="$DATABASE_URL" \ + learnault-api:$IMAGE_TAG \ + npx prisma migrate deploy + +# Deploy immutable image +echo "Deploying application containers..." +export IMAGE_TAG +docker compose -f docker-compose.staging.yml up -d + +echo "Awaiting readiness..." +# Wait for health endpoint +MAX_RETRIES=15 +RETRY_COUNT=0 +HEALTH_URL="http://localhost:5000/health" + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -s $HEALTH_URL | grep '"status":"ok"'; then + echo "" + echo "Application is ready!" + exit 0 + fi + echo "Waiting for app to be ready... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 + RETRY_COUNT=$((RETRY_COUNT+1)) +done + +echo "Application failed to become ready in time." +docker compose -f docker-compose.staging.yml logs api +exit 1 diff --git a/scripts/rollback-staging.sh b/scripts/rollback-staging.sh new file mode 100755 index 00000000..3d568673 --- /dev/null +++ b/scripts/rollback-staging.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -e + +PREVIOUS_IMAGE_TAG=$1 + +if [ -z "$PREVIOUS_IMAGE_TAG" ]; then + echo "Error: PREVIOUS_IMAGE_TAG must be provided for rollback." + echo "Usage: ./rollback-staging.sh " + exit 1 +fi + +echo "Initiating Rollback to image tag: $PREVIOUS_IMAGE_TAG" +echo "Note: This uses a migration-forward policy. Database is NOT rolled back." + +# Deploy previous image +export IMAGE_TAG=$PREVIOUS_IMAGE_TAG +docker compose -f docker-compose.staging.yml up -d + +echo "Awaiting readiness after rollback..." +# Wait for health endpoint +MAX_RETRIES=15 +RETRY_COUNT=0 +HEALTH_URL="http://localhost:5000/health" + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -s $HEALTH_URL | grep '"status":"ok"'; then + echo "" + echo "Rollback successful. Application is ready!" + exit 0 + fi + echo "Waiting for app to be ready... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 + RETRY_COUNT=$((RETRY_COUNT+1)) +done + +echo "Rollback application failed to become ready in time." +docker compose -f docker-compose.staging.yml logs api +exit 1 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 00000000..48ca2b7a --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +echo "Running Smoke Tests on Staging..." + +API_URL="http://localhost:5000" + +echo "Testing /health endpoint..." +curl -s -f "$API_URL/health" || { echo "Health check failed!"; exit 1; } + +echo "" +echo "Smoke tests passed successfully!"