Files
freeCodeCamp/.github/workflows/deploy-api.yml
2025-11-02 18:45:47 +05:30

298 lines
10 KiB
YAML

name: CD - Deploy - API
on:
workflow_dispatch:
inputs:
api_log_lvl:
description: 'Log level for the API'
type: choice
options:
- debug
- info
- warn
default: info
show_upcoming_changes:
description: 'Show upcoming changes (enables upcoming certifications and challenges)'
type: boolean
default: false
jobs:
setup-jobs:
name: Setup Jobs
runs-on: ubuntu-22.04
outputs:
site_tld: ${{ steps.setup.outputs.site_tld }}
tgt_env_short: ${{ steps.setup.outputs.tgt_env_short }}
tgt_env_long: ${{ steps.setup.outputs.tgt_env_long }}
api_log_lvl: ${{ steps.setup.outputs.api_log_lvl }}
show_upcoming_changes: ${{ steps.setup.outputs.show_upcoming_changes }}
steps:
- name: Setup
id: setup
run: |
BRANCH="${{ github.ref_name }}"
echo "Current branch: $BRANCH"
# Convert boolean input to string 'true' or 'false'
if [[ "${{ inputs.show_upcoming_changes }}" == "true" ]]; then
echo "show_upcoming_changes=true" >> $GITHUB_OUTPUT
else
echo "show_upcoming_changes=false" >> $GITHUB_OUTPUT
fi
case "$BRANCH" in
"prod-current")
echo "site_tld=org" >> $GITHUB_OUTPUT
echo "tgt_env_short=prd" >> $GITHUB_OUTPUT
echo "tgt_env_long=production" >> $GITHUB_OUTPUT
echo "api_log_lvl=${{ inputs.api_log_lvl || 'info' }}" >> $GITHUB_OUTPUT
;;
*)
echo "site_tld=dev" >> $GITHUB_OUTPUT
echo "tgt_env_short=stg" >> $GITHUB_OUTPUT
echo "tgt_env_long=staging" >> $GITHUB_OUTPUT
echo "api_log_lvl=${{ inputs.api_log_lvl || 'info' }}" >> $GITHUB_OUTPUT
;;
esac
build:
name: Build & Push
needs: setup-jobs
uses: ./.github/workflows/docker-docr.yml
with:
site_tld: ${{ needs.setup-jobs.outputs.site_tld }}
app: api
show_upcoming_changes: ${{ needs.setup-jobs.outputs.show_upcoming_changes }}
secrets: inherit
deploy:
name: Deploy to Docker Swarm -- ${{ needs.setup-jobs.outputs.tgt_env_short }}
runs-on: ubuntu-22.04
needs: [setup-jobs, build]
env:
TS_USERNAME: ${{ secrets.TS_USERNAME }}
TS_MACHINE_NAME: ${{ secrets.TS_MACHINE_NAME }}
permissions:
deployments: write
environment:
name: ${{ needs.setup-jobs.outputs.tgt_env_short }}-api
steps:
- name: Setup and connect to Tailscale network
uses: tailscale/github-action@6cae46e2d796f265265cfcf628b72a32b4d7cade # v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
hostname: gha-${{needs.setup-jobs.outputs.tgt_env_short}}-api-ci-${{ github.run_id }}
tags: tag:ci
version: latest
- name: Wait for Tailscale Network Readiness
run: |
echo "Waiting for Tailscale network to be ready..."
max_wait=60
elapsed=0
while [ $elapsed -lt $max_wait ]; do
if tailscale status --json | jq -e '.BackendState == "Running"' > /dev/null 2>&1; then
echo "Tailscale network is ready"
break
fi
sleep 2
elapsed=$((elapsed + 2))
done
if [ $elapsed -ge $max_wait ]; then
echo "Tailscale network not ready after ${max_wait}s"
exit 1
fi
- name: Configure SSH & Check Connection
run: |
mkdir -p ~/.ssh
echo "Host *
UserKnownHostsFile=/dev/null
StrictHostKeyChecking no" > ~/.ssh/config
chmod 644 ~/.ssh/config
validate_connection() {
local machine_name=$1
local max_retries=3
local retry_delay=5
for attempt in $(seq 1 $max_retries); do
echo "Connection attempt $attempt/$max_retries to $machine_name"
if ! tailscale status | grep -q "$machine_name"; then
echo "Machine $machine_name not found in Tailscale network"
if [ $attempt -eq $max_retries ]; then
return 1
fi
sleep $retry_delay
continue
fi
MACHINE_IP=$(tailscale ip -4 $machine_name)
if ssh -o ConnectTimeout=10 -o BatchMode=yes $TS_USERNAME@$MACHINE_IP "echo 'Connection test'; docker --version" > /dev/null 2>&1; then
echo "Successfully validated connection to $machine_name"
return 0
fi
echo "SSH validation failed for $machine_name"
if [ $attempt -lt $max_retries ]; then
sleep $retry_delay
fi
done
echo "Failed to establish connection to $machine_name after $max_retries attempts"
return 1
}
echo -e "\nLOG:Validating connection to $TS_MACHINE_NAME..."
if ! validate_connection "$TS_MACHINE_NAME"; then
echo "Error: Failed to establish reliable connection to $TS_MACHINE_NAME"
exit 1
fi
- name: Deploy with Docker Stack
env:
AGE_ENCRYPTED_ASC_SECRETS: ${{ secrets.AGE_ENCRYPTED_ASC_SECRETS }}
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
# Variable set from GitHub "Environment" secrets (AGE encrypted)
# DOCKER_REGISTRY
# MONGOHQ_URL
# SENTRY_DSN
# SENTRY_ENVIRONMENT
# AUTH0_CLIENT_ID
# AUTH0_CLIENT_SECRET
# AUTH0_DOMAIN
# JWT_SECRET
# COOKIE_SECRET
# COOKIE_DOMAIN
# SES_ID
# SES_SECRET
# GROWTHBOOK_FASTIFY_API_HOST
# GROWTHBOOK_FASTIFY_CLIENT_KEY
# HOME_LOCATION
# API_LOCATION
# STRIPE_SECRET_KEY
# LOKI_URL
# Variables set from SetupJob
DEPLOYMENT_VERSION: ${{ needs.build.outputs.tagname }}
DEPLOYMENT_ENV: ${{ needs.setup-jobs.outputs.tgt_env_long }}
DEPLOYMENT_TLD: ${{ needs.setup-jobs.outputs.site_tld }}
FCC_API_LOG_LEVEL: ${{ needs.setup-jobs.outputs.api_log_lvl }}
SHOW_UPCOMING_CHANGES: ${{ needs.setup-jobs.outputs.show_upcoming_changes }}
# Stack name
STACK_NAME: ${{ needs.setup-jobs.outputs.tgt_env_short }}-api
run: |
REMOTE_SCRIPT="
set -e
echo -e '\nLOG:Deploying API to $TS_MACHINE_NAME...'
cd /home/$TS_USERNAME/docker-swarm-config/stacks/api
echo -e '\nLOG:Checking if age is installed...'
which age > /dev/null
echo -e '\nLOG:Decrypting secrets...'
echo \"$AGE_ENCRYPTED_ASC_SECRETS\" > secrets.age.asc
echo \"$AGE_SECRET_KEY\" > age.key && chmod 600 age.key
age --identity age.key --decrypt secrets.age.asc > .env
rm -f age.key secrets.age.asc
echo -e '\nLOG:Cleaning up .env file...'
touch .env.tmp
while IFS= read -r line; do
if [[ \$line =~ ^[A-Za-z0-9_]+=.*$ ]]; then
# Extract the key (part before the first =)
key=\${line%%=*}
# Remove any previous line with this key
sed -i \"/^\${key}=/d\" .env.tmp
fi
# Append the current line
echo \"\$line\" >> .env.tmp
done < .env
mv .env.tmp .env
echo -e '\nLOG:Adding deployment variables...'
{
echo \"DEPLOYMENT_VERSION=$DEPLOYMENT_VERSION\"
echo \"DEPLOYMENT_TLD=$DEPLOYMENT_TLD\"
echo \"DEPLOYMENT_ENV=$DEPLOYMENT_ENV\"
echo \"FCC_API_LOG_LEVEL=$FCC_API_LOG_LEVEL\"
echo \"SHOW_UPCOMING_CHANGES=$SHOW_UPCOMING_CHANGES\"
} >> .env
echo -e '\nLOG:Sourcing environment...'
REQUIRED_VARS=(
\"DOCKER_REGISTRY\"
\"MONGOHQ_URL\"
\"SENTRY_DSN\"
\"SENTRY_ENVIRONMENT\"
\"AUTH0_CLIENT_ID\"
\"AUTH0_CLIENT_SECRET\"
\"AUTH0_DOMAIN\"
\"JWT_SECRET\"
\"COOKIE_SECRET\"
\"COOKIE_DOMAIN\"
\"SES_ID\"
\"SES_SECRET\"
\"GROWTHBOOK_FASTIFY_API_HOST\"
\"GROWTHBOOK_FASTIFY_CLIENT_KEY\"
\"HOME_LOCATION\"
\"API_LOCATION\"
\"STRIPE_SECRET_KEY\"
\"LOKI_URL\"
\"DEPLOYMENT_VERSION\"
\"DEPLOYMENT_TLD\"
\"DEPLOYMENT_ENV\"
\"FCC_API_LOG_LEVEL\"
)
while IFS='=' read -r key value; do
if [[ -n \"\$key\" && ! \"\$key\" =~ ^# ]]; then
export \"\${key}=\${value}\"
fi
done < .env
MISSING_VARS=()
for var in \"\${REQUIRED_VARS[@]}\"; do
if [[ -z \"\${!var}\" ]]; then
MISSING_VARS+=(\"\$var\")
fi
done
if [[ \${#MISSING_VARS[@]} -gt 0 ]]; then
echo \"ERROR: The following required environment variables are missing or empty:\"
for var in \"\${MISSING_VARS[@]}\"; do
echo \" - \$var\"
done
exit 1
fi
rm -rf .env
echo -e '\nLOG:Validating deployment version...'
if [[ \"\$DEPLOYMENT_VERSION\" != \"$DEPLOYMENT_VERSION\" ]]; then
echo \"Error: Version mismatch. Expected: $DEPLOYMENT_VERSION, Got: \$DEPLOYMENT_VERSION\"
exit 1
fi
env | grep -E 'DEPLOYMENT_VERSION'
echo -e '\nLOG:Checking stack configuration...'
CONFIG_OUTPUT=\"/dev/null\"
if [[ \"\$FCC_API_LOG_LEVEL\" == \"debug\" ]]; then
CONFIG_FILENAME=\"debug-docker-stack-config-\${DEPLOYMENT_VERSION}.yml\"
echo -e '\nLOG:Saving stack configuration for debugging...'
CONFIG_OUTPUT=\"\$CONFIG_FILENAME\"
fi
docker stack config -c stack-api.yml > \$CONFIG_OUTPUT
echo -e '\nLOG:Deploying stack...'
docker stack deploy -c stack-api.yml --prune --with-registry-auth --detach=false $STACK_NAME
echo -e '\nLOG:Finished deployment.'
"
MACHINE_IP=$(tailscale ip -4 $TS_MACHINE_NAME)
ssh $TS_USERNAME@$MACHINE_IP "$REMOTE_SCRIPT"