Support account lockout on repeated incorrect password entries #14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CodeDeploy - Production | |
| on: | |
| push: | |
| branches: | |
| - master | |
| paths: | |
| - 'gefapi/**' | |
| - 'migrations/**' | |
| - 'config/**' | |
| - 'scripts/codedeploy/**' | |
| - 'scripts/deployment/**' | |
| - 'docker-compose.yml' | |
| - 'docker-compose.prod.yml' | |
| - 'Dockerfile' | |
| - 'entrypoint.sh' | |
| - 'appspec.yml' | |
| - 'gunicorn.py' | |
| - 'main.py' | |
| - 'pyproject.toml' | |
| - 'poetry.lock' | |
| - 'run_db_migrations.py' | |
| - '.github/workflows/codedeploy_production.yml' | |
| workflow_dispatch: | |
| inputs: | |
| force_deploy: | |
| description: 'Force deployment even if no changes detected' | |
| required: false | |
| default: 'false' | |
| type: choice | |
| options: | |
| - 'true' | |
| - 'false' | |
| env: | |
| AWS_REGION: us-east-1 | |
| APPLICATION_NAME: trendsearth-api | |
| DEPLOYMENT_GROUP: trendsearth-api-production | |
| ECR_REPOSITORY: trendsearth-api | |
| concurrency: | |
| group: production-deployment | |
| cancel-in-progress: false | |
| # Required for OIDC authentication with AWS | |
| permissions: | |
| id-token: write | |
| contents: read | |
| jobs: | |
| build-and-push: | |
| name: Build and Push Docker Image | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| environment: production | |
| outputs: | |
| image-uri: ${{ steps.build-push.outputs.image-uri }} | |
| image-tag: ${{ steps.deployment-info.outputs.image-tag }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Get deployment info | |
| id: deployment-info | |
| run: | | |
| echo "commit-sha=${{ github.sha }}" >> $GITHUB_OUTPUT | |
| echo "short-sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT | |
| echo "timestamp=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_OUTPUT | |
| echo "image-tag=production-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT | |
| echo "branch=${{ github.ref_name }}" >> $GITHUB_OUTPUT | |
| - name: Configure AWS credentials via OIDC | |
| uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }} | |
| role-session-name: GitHubActionsProductionBuild | |
| aws-region: ${{ env.AWS_REGION }} | |
| - name: Login to Amazon ECR | |
| id: login-ecr | |
| uses: aws-actions/amazon-ecr-login@v2 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Ensure ECR repository exists | |
| id: ecr-repo | |
| env: | |
| ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} | |
| run: | | |
| REPO="${{ env.ECR_REPOSITORY }}" | |
| echo "repo-name=$REPO" >> $GITHUB_OUTPUT | |
| if ! aws ecr describe-repositories --repository-names "$REPO" 2>/dev/null; then | |
| echo "Creating ECR repository: $REPO" | |
| aws ecr create-repository \ | |
| --repository-name "$REPO" \ | |
| --image-scanning-configuration scanOnPush=true \ | |
| --image-tag-mutability MUTABLE | |
| else | |
| echo "ECR repository exists: $REPO" | |
| fi | |
| - name: Build and push Docker image to ECR | |
| id: build-push | |
| env: | |
| ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} | |
| IMAGE_TAG: ${{ steps.deployment-info.outputs.image-tag }} | |
| run: | | |
| IMAGE_URI="$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:$IMAGE_TAG" | |
| docker buildx build \ | |
| --platform linux/amd64 \ | |
| --cache-from=type=gha,scope=trendsearth-api-production \ | |
| --cache-to=type=gha,mode=max,scope=trendsearth-api-production \ | |
| --tag "$IMAGE_URI" \ | |
| --tag "$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:production-latest" \ | |
| --tag "$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest" \ | |
| --push \ | |
| . | |
| echo "image-uri=$IMAGE_URI" >> $GITHUB_OUTPUT | |
| echo "✅ Docker image pushed to ECR: $IMAGE_URI" | |
| deploy: | |
| name: Deploy to Production via CodeDeploy | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| environment: production | |
| needs: build-and-push | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Configure AWS credentials via OIDC | |
| uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }} | |
| role-session-name: GitHubActionsProductionDeploy | |
| aws-region: ${{ env.AWS_REGION }} | |
| - name: Login to Amazon ECR | |
| id: login-ecr | |
| uses: aws-actions/amazon-ecr-login@v2 | |
| - name: Get deployment info | |
| id: deployment-info | |
| run: | | |
| echo "commit-sha=${{ github.sha }}" >> $GITHUB_OUTPUT | |
| echo "short-sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT | |
| echo "timestamp=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_OUTPUT | |
| # Get AWS account ID for S3 bucket name | |
| ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) | |
| echo "s3-bucket=trendsearth-api-deployments-${ACCOUNT_ID}" >> $GITHUB_OUTPUT | |
| - name: Get image references | |
| id: images | |
| env: | |
| ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} | |
| run: | | |
| echo "api-image=${{ needs.build-and-push.outputs.image-uri }}" >> $GITHUB_OUTPUT | |
| echo "ecr-registry=$ECR_REGISTRY" >> $GITHUB_OUTPUT | |
| echo "✅ API image: ${{ needs.build-and-push.outputs.image-uri }}" | |
| - name: Create environment file for deployment | |
| env: | |
| ECR_REGISTRY: ${{ steps.images.outputs.ecr-registry }} | |
| API_IMAGE: ${{ steps.images.outputs.api-image }} | |
| SECRET_KEY: ${{ secrets.SECRET_KEY }} | |
| JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} | |
| DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }} | |
| # GEE Configuration | |
| EE_SERVICE_ACCOUNT_JSON: ${{ secrets.EE_SERVICE_ACCOUNT_JSON }} | |
| GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID }} | |
| GEE_ENDPOINT: ${{ vars.GEE_ENDPOINT || 'https://earthengine-highvolume.googleapis.com' }} | |
| GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }} | |
| GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} | |
| GOOGLE_OAUTH_REDIRECT_URI: ${{ vars.PRODUCTION_GOOGLE_OAUTH_REDIRECT_URI }} | |
| # Rollbar | |
| ROLLBAR_SCRIPT_TOKEN: ${{ secrets.ROLLBAR_SCRIPT_TOKEN }} | |
| ROLLBAR_SERVER_TOKEN: ${{ secrets.ROLLBAR_SERVER_TOKEN }} | |
| API_PUBLIC_URL: ${{ vars.PRODUCTION_API_PUBLIC_URL }} | |
| # S3 Configuration | |
| SCRIPTS_S3_BUCKET: ${{ vars.SCRIPTS_S3_BUCKET }} | |
| SCRIPTS_S3_PREFIX: ${{ vars.PRODUCTION_SCRIPTS_S3_PREFIX || 'api-files/scripts/production' }} | |
| PARAMS_S3_BUCKET: ${{ vars.PARAMS_S3_BUCKET }} | |
| PARAMS_S3_PREFIX: ${{ vars.PRODUCTION_PARAMS_S3_PREFIX || 'api-files/params/production' }} | |
| # Docker Configuration | |
| REGISTRY_URL: ${{ vars.REGISTRY_URL }} | |
| DOCKER_SUBNET: ${{ vars.DOCKER_SUBNET || '10.10.0.0/16' }} | |
| EXECUTION_SUBNET: ${{ vars.EXECUTION_SUBNET || '10.11.0.0/24' }} | |
| SPARKPOST_API_KEY: ${{ secrets.SPARKPOST_API_KEY }} | |
| API_ENVIRONMENT_USER: ${{ secrets.API_ENVIRONMENT_USER }} | |
| API_ENVIRONMENT_USER_PASSWORD: ${{ secrets.API_ENVIRONMENT_USER_PASSWORD }} | |
| CORS_ORIGINS: ${{ vars.PRODUCTION_CORS_ORIGINS || 'https://trends.earth,https://api.trends.earth' }} | |
| # Rate Limiting | |
| RATE_LIMITING_ENABLED: ${{ vars.RATE_LIMITING_ENABLED || 'true' }} | |
| RATE_LIMIT_DEFAULT_LIMITS: ${{ vars.RATE_LIMIT_DEFAULT_LIMITS || '1000 per hour,100 per minute' }} | |
| RATE_LIMIT_API_LIMITS: ${{ vars.RATE_LIMIT_API_LIMITS || '300 per hour,20 per minute' }} | |
| RATE_LIMIT_AUTH_LIMITS: ${{ vars.RATE_LIMIT_AUTH_LIMITS || '60 per minute,600 per hour' }} | |
| RATE_LIMIT_PASSWORD_RESET_LIMITS: ${{ vars.RATE_LIMIT_PASSWORD_RESET_LIMITS || '3 per hour,1 per minute' }} | |
| RATE_LIMIT_USER_CREATION_LIMITS: ${{ vars.RATE_LIMIT_USER_CREATION_LIMITS || '100 per hour' }} | |
| RATE_LIMIT_EXECUTION_RUN_LIMITS: ${{ vars.RATE_LIMIT_EXECUTION_RUN_LIMITS || '10 per minute,40 per hour,150 per day' }} | |
| RATE_LIMIT_TRUSTED_PROXY_COUNT: ${{ vars.RATE_LIMIT_TRUSTED_PROXY_COUNT || '1' }} | |
| RATE_LIMIT_INTERNAL_NETWORKS: ${{ vars.RATE_LIMIT_INTERNAL_NETWORKS || '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' }} | |
| run: | | |
| # Generate prod.env with secrets from GitHub | |
| # This file is included in the deployment package and copied to the server | |
| # Values are NOT quoted - Docker env_file reads quotes as literal characters | |
| # The CodeDeploy scripts use safe_source_env() to handle special chars in values | |
| { | |
| echo "# Generated by GitHub Actions" | |
| echo "# Commit: ${{ github.sha }}" | |
| echo "" | |
| echo "# ECR Images (pre-built in CI)" | |
| echo "ECR_REGISTRY=$ECR_REGISTRY" | |
| echo "API_IMAGE=$API_IMAGE" | |
| echo "" | |
| echo "# Environment" | |
| echo "ENVIRONMENT=production" | |
| echo "DEBUG=False" | |
| echo "TESTING=false" | |
| echo "" | |
| echo "# Flask/API Configuration" | |
| echo "SECRET_KEY=$SECRET_KEY" | |
| echo "JWT_SECRET_KEY=$JWT_SECRET_KEY" | |
| echo "API_ENVIRONMENT_USER=$API_ENVIRONMENT_USER" | |
| echo "API_ENVIRONMENT_USER_PASSWORD=$API_ENVIRONMENT_USER_PASSWORD" | |
| echo "" | |
| echo "# Database Configuration" | |
| echo "DATABASE_URL=$DATABASE_URL" | |
| echo "" | |
| echo "# Redis Configuration (uses stack Redis service)" | |
| echo "REDIS_URL=redis://redis:6379/0" | |
| echo "" | |
| echo "# Rate Limiting" | |
| echo "RATE_LIMITING_ENABLED=$RATE_LIMITING_ENABLED" | |
| echo "RATE_LIMIT_STORAGE_URI=redis://redis:6379/1" | |
| echo "DEFAULT_LIMITS=$RATE_LIMIT_DEFAULT_LIMITS" | |
| echo "API_LIMITS=$RATE_LIMIT_API_LIMITS" | |
| echo "AUTH_LIMITS=$RATE_LIMIT_AUTH_LIMITS" | |
| echo "PASSWORD_RESET_LIMITS=$RATE_LIMIT_PASSWORD_RESET_LIMITS" | |
| echo "USER_CREATION_LIMITS=$RATE_LIMIT_USER_CREATION_LIMITS" | |
| echo "EXECUTION_RUN_LIMITS=$RATE_LIMIT_EXECUTION_RUN_LIMITS" | |
| echo "TRUSTED_PROXY_COUNT=$RATE_LIMIT_TRUSTED_PROXY_COUNT" | |
| echo "INTERNAL_NETWORKS=$RATE_LIMIT_INTERNAL_NETWORKS" | |
| echo "" | |
| echo "# Google Earth Engine" | |
| echo "EE_SERVICE_ACCOUNT_JSON=$EE_SERVICE_ACCOUNT_JSON" | |
| echo "GOOGLE_PROJECT_ID=$GOOGLE_PROJECT_ID" | |
| echo "GEE_ENDPOINT=$GEE_ENDPOINT" | |
| echo "GOOGLE_OAUTH_CLIENT_ID=$GOOGLE_OAUTH_CLIENT_ID" | |
| echo "GOOGLE_OAUTH_CLIENT_SECRET=$GOOGLE_OAUTH_CLIENT_SECRET" | |
| echo "GOOGLE_OAUTH_REDIRECT_URI=$GOOGLE_OAUTH_REDIRECT_URI" | |
| echo "" | |
| echo "# Rollbar Error Tracking" | |
| echo "ROLLBAR_SCRIPT_TOKEN=$ROLLBAR_SCRIPT_TOKEN" | |
| echo "ROLLBAR_SERVER_TOKEN=$ROLLBAR_SERVER_TOKEN" | |
| echo "" | |
| echo "# API URLs" | |
| echo "API_PUBLIC_URL=$API_PUBLIC_URL" | |
| echo "API_INTERNAL_URL=http://api:3000" | |
| echo "" | |
| echo "# S3 Configuration (uses EC2 instance role for credentials)" | |
| echo "SCRIPTS_S3_BUCKET=$SCRIPTS_S3_BUCKET" | |
| echo "SCRIPTS_S3_PREFIX=$SCRIPTS_S3_PREFIX" | |
| echo "PARAMS_S3_BUCKET=$PARAMS_S3_BUCKET" | |
| echo "PARAMS_S3_PREFIX=$PARAMS_S3_PREFIX" | |
| echo "" | |
| echo "# Docker Configuration" | |
| echo "REGISTRY_URL=$REGISTRY_URL" | |
| echo "DOCKER_SUBNET=$DOCKER_SUBNET" | |
| echo "EXECUTION_SUBNET=$EXECUTION_SUBNET" | |
| echo "" | |
| echo "# Email Configuration (SparkPost)" | |
| echo "SPARKPOST_API_KEY=$SPARKPOST_API_KEY" | |
| echo "" | |
| echo "# CORS Configuration" | |
| echo "CORS_ORIGINS=$CORS_ORIGINS" | |
| echo "" | |
| echo "# Deployment info" | |
| echo "GIT_REVISION=${{ github.sha }}" | |
| echo "GIT_BRANCH=${{ github.ref_name }}" | |
| echo "DEPLOYMENT_ENVIRONMENT=production" | |
| } > prod.env | |
| echo "✅ Created prod.env with $(wc -l < prod.env) lines" | |
| - name: Update appspec.yml for production | |
| run: | | |
| # Modify appspec.yml to use production-specific destination | |
| # This prevents race conditions when staging and production deploy simultaneously | |
| sed -i 's|destination: /opt/trendsearth-api|destination: /opt/trendsearth-api-production|g' appspec.yml | |
| echo "Updated appspec.yml destination to /opt/trendsearth-api-production" | |
| cat appspec.yml | |
| - name: Create deployment package | |
| id: package | |
| run: | | |
| # Create a deployment archive | |
| REVISION_FILE="production-${{ steps.deployment-info.outputs.timestamp }}-${{ steps.deployment-info.outputs.short-sha }}.zip" | |
| echo "revision-file=$REVISION_FILE" >> $GITHUB_OUTPUT | |
| # Include only files needed for deployment (not source code - that's in the Docker image) | |
| zip -r "$REVISION_FILE" \ | |
| appspec.yml \ | |
| prod.env \ | |
| docker-compose.yml \ | |
| docker-compose.prod.yml \ | |
| scripts/codedeploy/ \ | |
| config/db/ \ | |
| entrypoint.sh \ | |
| -x "*.pyc" \ | |
| -x "__pycache__/*" \ | |
| -x ".git/*" \ | |
| -x ".venv/*" \ | |
| -x "*.log" | |
| echo "Created deployment package: $REVISION_FILE ($(du -h $REVISION_FILE | cut -f1))" | |
| - name: Upload deployment package to S3 | |
| id: upload | |
| run: | | |
| BUCKET="${{ steps.deployment-info.outputs.s3-bucket }}" | |
| KEY="production/${{ steps.package.outputs.revision-file }}" | |
| aws s3 cp "${{ steps.package.outputs.revision-file }}" "s3://$BUCKET/$KEY" | |
| echo "s3-key=$KEY" >> $GITHUB_OUTPUT | |
| echo "Uploaded to s3://$BUCKET/$KEY" | |
| - name: Stop any in-progress deployments | |
| run: | | |
| echo "🔍 Checking for in-progress deployments..." | |
| # Get list of active deployments | |
| ACTIVE_DEPLOYMENTS=$(aws deploy list-deployments \ | |
| --application-name ${{ env.APPLICATION_NAME }} \ | |
| --deployment-group-name ${{ env.DEPLOYMENT_GROUP }} \ | |
| --include-only-statuses "InProgress" "Queued" "Created" \ | |
| --query 'deployments' \ | |
| --output text 2>/dev/null || echo "") | |
| if [ -n "$ACTIVE_DEPLOYMENTS" ] && [ "$ACTIVE_DEPLOYMENTS" != "None" ]; then | |
| for DEPLOYMENT_ID in $ACTIVE_DEPLOYMENTS; do | |
| echo "⏹️ Stopping deployment: $DEPLOYMENT_ID" | |
| aws deploy stop-deployment \ | |
| --deployment-id "$DEPLOYMENT_ID" \ | |
| --auto-rollback-enabled 2>/dev/null || true | |
| done | |
| # Wait for deployments to stop | |
| echo "⏳ Waiting for deployments to stop..." | |
| sleep 15 | |
| echo "✅ Previous deployments stopped" | |
| else | |
| echo "✅ No active deployments found" | |
| fi | |
| - name: Create CodeDeploy deployment | |
| id: deploy | |
| run: | | |
| BUCKET="${{ steps.deployment-info.outputs.s3-bucket }}" | |
| KEY="${{ steps.upload.outputs.s3-key }}" | |
| DEPLOYMENT_ID=$(aws deploy create-deployment \ | |
| --application-name ${{ env.APPLICATION_NAME }} \ | |
| --deployment-group-name ${{ env.DEPLOYMENT_GROUP }} \ | |
| --s3-location bucket=$BUCKET,key=$KEY,bundleType=zip \ | |
| --deployment-config-name CodeDeployDefault.OneAtATime \ | |
| --description "Production deployment from ${{ github.ref_name }}" \ | |
| --file-exists-behavior OVERWRITE \ | |
| --query 'deploymentId' \ | |
| --output text) | |
| echo "deployment-id=$DEPLOYMENT_ID" >> $GITHUB_OUTPUT | |
| echo "🚀 Created deployment: $DEPLOYMENT_ID" | |
| - name: Wait for deployment to complete | |
| run: | | |
| DEPLOYMENT_ID="${{ steps.deploy.outputs.deployment-id }}" | |
| echo "⏳ Waiting for deployment $DEPLOYMENT_ID to complete..." | |
| # Wait for deployment (max 30 minutes) | |
| aws deploy wait deployment-successful \ | |
| --deployment-id "$DEPLOYMENT_ID" \ | |
| --cli-read-timeout 1800 || { | |
| # Get deployment info if failed | |
| STATUS=$(aws deploy get-deployment --deployment-id "$DEPLOYMENT_ID" \ | |
| --query 'deploymentInfo.status' --output text) | |
| echo "❌ Deployment failed with status: $STATUS" | |
| # Get deployment events for debugging | |
| echo "📋 Deployment events:" | |
| aws deploy list-deployment-targets --deployment-id "$DEPLOYMENT_ID" \ | |
| --query 'targetIds' --output text | while read TARGET_ID; do | |
| echo "Target: $TARGET_ID" | |
| aws deploy get-deployment-target \ | |
| --deployment-id "$DEPLOYMENT_ID" \ | |
| --target-id "$TARGET_ID" \ | |
| --query 'deploymentTarget.instanceTarget.lifecycleEvents[*].{Event:lifecycleEventName,Status:status,Message:diagnostics.message}' \ | |
| --output table | |
| done | |
| exit 1 | |
| } | |
| echo "✅ Deployment completed successfully!" | |
| - name: Verify deployment | |
| run: | | |
| echo "🔍 Verifying deployment..." | |
| # Get deployment status | |
| DEPLOYMENT_ID="${{ steps.deploy.outputs.deployment-id }}" | |
| aws deploy get-deployment \ | |
| --deployment-id "$DEPLOYMENT_ID" \ | |
| --query 'deploymentInfo.{Status:status,CreateTime:createTime,CompleteTime:completeTime}' \ | |
| --output table | |
| - name: Deployment notification | |
| if: always() | |
| run: | | |
| if [ "${{ job.status }}" = "success" ]; then | |
| echo "✅ Production deployment successful!" | |
| echo "🌐 Site: https://api.trends.earth" | |
| echo "📦 Commit: ${{ github.sha }}" | |
| echo "🚀 Deployment ID: ${{ steps.deploy.outputs.deployment-id }}" | |
| else | |
| echo "❌ Production deployment failed!" | |
| echo "📦 Commit: ${{ github.sha }}" | |
| echo "🚀 Deployment ID: ${{ steps.deploy.outputs.deployment-id }}" | |
| exit 1 | |
| fi | |
| - name: Notify Rollbar of deployment | |
| if: success() | |
| uses: rollbar/github-deploy-action@2.1.2 | |
| with: | |
| environment: 'production' | |
| version: ${{ github.sha }} | |
| status: 'succeeded' | |
| local_username: ${{ github.actor }} | |
| env: | |
| ROLLBAR_ACCESS_TOKEN: ${{ secrets.ROLLBAR_SERVER_TOKEN }} | |
| continue-on-error: true | |
| - name: Notify Rollbar of deployment failure | |
| if: failure() | |
| uses: rollbar/github-deploy-action@2.1.2 | |
| with: | |
| environment: 'production' | |
| version: ${{ github.sha }} | |
| status: 'failed' | |
| local_username: ${{ github.actor }} | |
| env: | |
| ROLLBAR_ACCESS_TOKEN: ${{ secrets.ROLLBAR_SERVER_TOKEN }} | |
| continue-on-error: true |