diff --git a/.github/workflows/sandbox-down.yml b/.github/workflows/sandbox-down.yml new file mode 100644 index 0000000000..45fe7254d7 --- /dev/null +++ b/.github/workflows/sandbox-down.yml @@ -0,0 +1,83 @@ +name: Sandbox Tear-Down + +on: + pull_request: + types: [closed] + branches: + - sandbox + +# Share the concurrency group with sandbox-up so they can't run simultaneously. +concurrency: + group: sandbox + cancel-in-progress: false + +permissions: + contents: read + +jobs: + tear-down: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event.pull_request.merged == true + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Check if sandbox exists + id: check + run: | + STATUS=$(aws elasticbeanstalk describe-environments \ + --environment-names finishline-sandbox-env \ + --region us-east-2 \ + --query "Environments[0].Status" \ + --output text 2>/dev/null || echo "None") + + if [ "$STATUS" = "None" ] || [ "$STATUS" = "" ] || [ "$STATUS" = "Terminated" ]; then + echo "No active sandbox found, nothing to tear down." + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "Sandbox found with status: $STATUS, proceeding with teardown." + echo "exists=true" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Terraform + if: steps.check.outputs.exists == 'true' + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "~1.0" + terraform_wrapper: false + + - name: Terraform init + if: steps.check.outputs.exists == 'true' + working-directory: infrastructure/environments/sandbox + run: terraform init + + - name: Terraform destroy + if: steps.check.outputs.exists == 'true' + working-directory: infrastructure/environments/sandbox + env: + # Terraform requires all required variables to have values even for destroy. + # The actual values are irrelevant since destroy only reads state. + TF_VAR_db_master_password: "unused" + TF_VAR_session_secret: "unused" + TF_VAR_encryption_key: "unused" + TF_VAR_google_client_secret: "unused" + TF_VAR_drive_refresh_token: "unused" + TF_VAR_calendar_refresh_token: "unused" + TF_VAR_slack_bot_token: "unused" + TF_VAR_slack_token_secret: "unused" + TF_VAR_slack_signing_secret: "unused" + TF_VAR_notification_endpoint_secret: "unused" + run: terraform destroy -auto-approve + + - name: Tag-based cleanup safety net + if: steps.check.outputs.exists == 'true' + run: bash infrastructure/scripts/cleanup-sandbox.sh diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml new file mode 100644 index 0000000000..7c91a11e42 --- /dev/null +++ b/.github/workflows/sandbox-up.yml @@ -0,0 +1,329 @@ +name: Sandbox Spin-Up + +on: + pull_request: + branches: + - sandbox + types: [opened, synchronize, reopened] + workflow_dispatch: + +# Only one sandbox may exist at a time. +concurrency: + group: sandbox + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + spin-up: + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: sandbox + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Fail fast if sandbox already exists + run: | + STATUS=$(aws elasticbeanstalk describe-environments \ + --environment-names finishline-sandbox-env \ + --region us-east-2 \ + --query "Environments[0].Status" \ + --output text 2>/dev/null || echo "None") + + if [ "$STATUS" != "None" ] && [ "$STATUS" != "" ] && [ "$STATUS" != "Terminated" ]; then + echo "Sandbox already exists with status: $STATUS" + echo "Tear it down first with the sandbox-down workflow." + exit 1 + fi + + - name: Take prod RDS snapshot + id: snapshot + run: | + SNAPSHOT_ID="finishline-prod-presandbox-$(date +%Y%m%d%H%M%S)" + + aws rds create-db-snapshot \ + --db-instance-identifier finishline-production-db \ + --db-snapshot-identifier "$SNAPSHOT_ID" \ + --region us-east-1 + + echo "Waiting for snapshot to become available (~5 min)..." + aws rds wait db-snapshot-available \ + --db-snapshot-identifier "$SNAPSHOT_ID" \ + --region us-east-1 + + echo "us_east_1_snapshot_id=$SNAPSHOT_ID" >> "$GITHUB_OUTPUT" + + - name: Copy snapshot to us-east-2 + id: snapshot_copy + run: | + SOURCE_SNAPSHOT="${{ steps.snapshot.outputs.us_east_1_snapshot_id }}" + COPY_ID="${SOURCE_SNAPSHOT}-us-east-2" + SOURCE_ARN=$(aws rds describe-db-snapshots \ + --db-snapshot-identifier "$SOURCE_SNAPSHOT" \ + --region us-east-1 \ + --query "DBSnapshots[0].DBSnapshotArn" \ + --output text) + + aws rds copy-db-snapshot \ + --source-db-snapshot-identifier "$SOURCE_ARN" \ + --target-db-snapshot-identifier "$COPY_ID" \ + --kms-key-id alias/aws/rds \ + --region us-east-2 + + echo "Waiting for snapshot copy to become available (~5 min)..." + aws rds wait db-snapshot-available \ + --db-snapshot-identifier "$COPY_ID" \ + --region us-east-2 + + echo "snapshot_id=$COPY_ID" >> "$GITHUB_OUTPUT" + + - name: Pull prod secrets from Secrets Manager + run: | + fetch() { + aws secretsmanager get-secret-value \ + --secret-id "finishline/production/$1" \ + --region us-east-1 \ + --query SecretString \ + --output text + } + + SESSION_SECRET=$(fetch session-secret) + ENCRYPTION_KEY=$(fetch encryption-key) + GOOGLE_CLIENT_SECRET=$(fetch google-client-secret) + DRIVE_REFRESH_TOKEN=$(fetch drive-refresh-token) + CALENDAR_REFRESH_TOKEN=$(fetch calendar-refresh-token) + SLACK_BOT_TOKEN=$(fetch slack-bot-token) + SLACK_SIGNING_SECRET=$(fetch slack-signing-secret) + NOTIFICATION_ENDPOINT_SECRET=$(fetch notification-endpoint-secret) + + echo "::add-mask::$SESSION_SECRET" + echo "::add-mask::$ENCRYPTION_KEY" + echo "::add-mask::$GOOGLE_CLIENT_SECRET" + echo "::add-mask::$DRIVE_REFRESH_TOKEN" + echo "::add-mask::$CALENDAR_REFRESH_TOKEN" + echo "::add-mask::$SLACK_BOT_TOKEN" + echo "::add-mask::$SLACK_SIGNING_SECRET" + echo "::add-mask::$NOTIFICATION_ENDPOINT_SECRET" + + { + echo "TF_VAR_session_secret=$SESSION_SECRET" + echo "TF_VAR_encryption_key=$ENCRYPTION_KEY" + echo "TF_VAR_google_client_secret=$GOOGLE_CLIENT_SECRET" + echo "TF_VAR_drive_refresh_token=$DRIVE_REFRESH_TOKEN" + echo "TF_VAR_calendar_refresh_token=$CALENDAR_REFRESH_TOKEN" + echo "TF_VAR_slack_bot_token=$SLACK_BOT_TOKEN" + echo "TF_VAR_slack_signing_secret=$SLACK_SIGNING_SECRET" + echo "TF_VAR_notification_endpoint_secret=$NOTIFICATION_ENDPOINT_SECRET" + } >> "$GITHUB_ENV" + + - name: Pull non-secret config from prod EB environment + run: | + eb_var() { + aws elasticbeanstalk describe-configuration-settings \ + --application-name finishline-production \ + --environment-name finishline-production-env \ + --region us-east-1 \ + --query "ConfigurationSettings[0].OptionSettings[?Namespace=='aws:elasticbeanstalk:application:environment'&&OptionName=='$1'].Value" \ + --output text + } + + SLACK_TOKEN_SECRET=$(eb_var SLACK_TOKEN_SECRET) + echo "::add-mask::$SLACK_TOKEN_SECRET" + + { + echo "TF_VAR_slack_token_secret=$SLACK_TOKEN_SECRET" + echo "TF_VAR_google_client_id=$(eb_var GOOGLE_CLIENT_ID)" + echo "TF_VAR_google_drive_folder_id=$(eb_var GOOGLE_DRIVE_FOLDER_ID)" + echo "TF_VAR_slack_id=$(eb_var SLACK_ID)" + echo "TF_VAR_user_email=$(eb_var USER_EMAIL)" + echo "TF_VAR_admin_user_id=$(eb_var ADMIN_USER_ID)" + } >> "$GITHUB_ENV" + + - name: Generate sandbox DB password + run: | + DB_PASSWORD=$(openssl rand -base64 24 | tr -d "=+/") + echo "::add-mask::$DB_PASSWORD" + echo "TF_VAR_db_master_password=$DB_PASSWORD" >> "$GITHUB_ENV" + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "~1.0" + terraform_wrapper: false + + - name: Terraform init + working-directory: infrastructure/environments/sandbox + run: terraform init + + - name: Terraform apply + working-directory: infrastructure/environments/sandbox + env: + TF_VAR_snapshot_identifier: ${{ steps.snapshot_copy.outputs.snapshot_id }} + run: terraform apply -auto-approve + + - name: Wait for Amplify custom domain to become active + working-directory: infrastructure/environments/sandbox + run: | + APP_ID=$(terraform output -raw amplify_app_id) + DOMAIN="qa.finishlinebyner.com" + + # finishlinebyner.com's hosted zone is in this same AWS account, so Amplify + # automatically creates and manages the cert-verification and subdomain + # routing DNS records itself — no manual Route53 writes needed here. + echo "Waiting for Amplify domain to become active..." + while true; do + STATUS=$(aws amplify get-domain-association \ + --app-id "$APP_ID" \ + --domain-name "$DOMAIN" \ + --region us-east-2 \ + --query "domainAssociation.domainStatus" \ + --output text) + echo " domain status: $STATUS" + case "$STATUS" in + AVAILABLE) echo "Domain is active."; break ;; + FAILED) echo "Domain verification failed."; exit 1 ;; + *) sleep 30 ;; + esac + done + + - name: Build and deploy frontend to Amplify + working-directory: infrastructure/environments/sandbox + run: | + APP_ID=$(terraform output -raw amplify_app_id) + + # Install deps and build + cd "$GITHUB_WORKSPACE" + yarn install --frozen-lockfile + yarn workspace shared build + + # Set Vite env vars required at build time + # Must use the custom domain, not the raw EB CNAME: the backend's + # cert only matches qa.api.finishlinebyner.com. + export VITE_REACT_APP_BACKEND_URL=$(cd infrastructure/environments/sandbox && terraform output -raw backend_url) + export VITE_REACT_APP_GOOGLE_AUTH_CLIENT_ID="$TF_VAR_google_client_id" + + yarn workspace frontend build + + # Zip the built artifacts (files at root, not nested) + cd src/frontend/dist + zip -r /tmp/frontend.zip . + cd "$GITHUB_WORKSPACE" + + # Create a manual deployment slot and get the upload URL + DEPLOY=$(aws amplify create-deployment \ + --app-id "$APP_ID" \ + --branch-name develop \ + --region us-east-2) + JOB_ID=$(echo "$DEPLOY" | jq -r '.jobId') + UPLOAD_URL=$(echo "$DEPLOY" | jq -r '.zipUploadUrl') + + # Upload the artifact + curl -X PUT "$UPLOAD_URL" \ + -H "Content-Type: application/zip" \ + --data-binary @/tmp/frontend.zip + + # Start the deployment + aws amplify start-deployment \ + --app-id "$APP_ID" \ + --branch-name develop \ + --job-id "$JOB_ID" \ + --region us-east-2 + + echo "Waiting for Amplify deployment..." + while true; do + STATUS=$(aws amplify get-job \ + --app-id "$APP_ID" \ + --branch-name develop \ + --job-id "$JOB_ID" \ + --region us-east-2 \ + --query "job.summary.status" \ + --output text) + echo " status: $STATUS" + case "$STATUS" in + SUCCEED) echo "Frontend deployed."; break ;; + FAILED|CANCELLED) echo "Amplify deployment $STATUS."; exit 1 ;; + *) sleep 30 ;; + esac + done + + - name: Get sandbox URLs + id: urls + working-directory: infrastructure/environments/sandbox + run: | + echo "eb_url=$(terraform output -raw eb_environment_url)" >> "$GITHUB_OUTPUT" + echo "eb_cname=$(terraform output -raw eb_cname)" >> "$GITHUB_OUTPUT" + echo "frontend_url=$(terraform output -raw frontend_url)" >> "$GITHUB_OUTPUT" + + - name: Build and push candidate image to prod ECR repo + run: | + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com" + + aws ecr get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin "$ECR_REGISTRY" + ECR_REPOSITORY="finishline-production" + + # Build from this branch and push to the SAME prod ECR repo (no separate + # sandbox repo — ECR has no concept of environments) under a distinct tag, + # so sandbox tests the exact image that would be promoted to prod, rather + # than whatever was last actually deployed to prod. + SANDBOX_IMAGE_TAG="sandbox-${{ github.sha }}" + docker build --tag "$ECR_REGISTRY/$ECR_REPOSITORY:$SANDBOX_IMAGE_TAG" . + docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$SANDBOX_IMAGE_TAG" + + echo "SANDBOX_IMAGE_TAG=$SANDBOX_IMAGE_TAG" >> "$GITHUB_ENV" + echo "ECR_REGISTRY=$ECR_REGISTRY" >> "$GITHUB_ENV" + echo "ECR_REPOSITORY=$ECR_REPOSITORY" >> "$GITHUB_ENV" + + - name: Deploy app to sandbox EB + run: | + cat > Dockerrun.aws.json </dev/null | tr '\t' '\n' | grep -v '^$' || true +} + +##################### +# Elastic Beanstalk +##################### +log "Terminating sandbox EB environments..." +for env in $(aws elasticbeanstalk describe-environments \ + --region "$REGION" \ + --query "Environments[?Tags[?Key=='$TAG_KEY' && Value=='$TAG_VALUE']].EnvironmentName" \ + --output text 2>/dev/null || true); do + log " Terminating EB environment: $env" + aws elasticbeanstalk terminate-environment \ + --environment-name "$env" \ + --region "$REGION" || true +done + +# Wait for EB environments to finish terminating before touching VPC resources +for env in $(aws elasticbeanstalk describe-environments \ + --region "$REGION" \ + --query "Environments[?Tags[?Key=='$TAG_KEY' && Value=='$TAG_VALUE'] && Status!='Terminated'].EnvironmentName" \ + --output text 2>/dev/null || true); do + log " Waiting for EB environment to terminate: $env" + aws elasticbeanstalk wait environment-terminated \ + --environment-name "$env" \ + --region "$REGION" || true +done + +##################### +# RDS Instances +##################### +log "Deleting sandbox RDS instances..." +for db in $(aws rds describe-db-instances \ + --region "$REGION" \ + --query "DBInstances[?TagList[?Key=='$TAG_KEY' && Value=='$TAG_VALUE']].DBInstanceIdentifier" \ + --output text 2>/dev/null || true); do + log " Deleting RDS instance: $db" + aws rds delete-db-instance \ + --db-instance-identifier "$db" \ + --skip-final-snapshot \ + --region "$REGION" || true + aws rds wait db-instance-deleted \ + --db-instance-identifier "$db" \ + --region "$REGION" || true +done + +log "Deleting sandbox DB subnet groups..." +for sg in $(aws rds describe-db-subnet-groups \ + --region "$REGION" \ + --query "DBSubnetGroups[?Tags[?Key=='$TAG_KEY' && Value=='$TAG_VALUE']].DBSubnetGroupName" \ + --output text 2>/dev/null || true); do + log " Deleting DB subnet group: $sg" + aws rds delete-db-subnet-group \ + --db-subnet-group-name "$sg" \ + --region "$REGION" || true +done + +##################### +# CloudWatch Log Groups +##################### +log "Deleting sandbox CloudWatch log groups..." +for lg in $(aws logs describe-log-groups \ + --region "$REGION" \ + --log-group-name-prefix "/aws/elasticbeanstalk/finishline-sandbox" \ + --query "logGroups[].logGroupName" \ + --output text 2>/dev/null || true); do + log " Deleting log group: $lg" + aws logs delete-log-group --log-group-name "$lg" --region "$REGION" || true +done + +##################### +# VPC Resources +# Must delete in dependency order: IGW → subnets → route table associations +# → non-main route tables → security group rules → security groups → VPC +##################### +for vpc in $(aws ec2 describe-vpcs \ + --region "$REGION" \ + --filters "Name=tag:$TAG_KEY,Values=$TAG_VALUE" \ + --query "Vpcs[].VpcId" \ + --output text 2>/dev/null || true); do + + log "Cleaning up VPC: $vpc" + + # Detach and delete internet gateways + for igw in $(aws ec2 describe-internet-gateways \ + --region "$REGION" \ + --filters "Name=attachment.vpc-id,Values=$vpc" \ + --query "InternetGateways[].InternetGatewayId" \ + --output text 2>/dev/null || true); do + log " Detaching IGW: $igw" + aws ec2 detach-internet-gateway --internet-gateway-id "$igw" --vpc-id "$vpc" --region "$REGION" || true + log " Deleting IGW: $igw" + aws ec2 delete-internet-gateway --internet-gateway-id "$igw" --region "$REGION" || true + done + + # Delete subnets + for subnet in $(aws ec2 describe-subnets \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "Subnets[].SubnetId" \ + --output text 2>/dev/null || true); do + log " Deleting subnet: $subnet" + aws ec2 delete-subnet --subnet-id "$subnet" --region "$REGION" || true + done + + # Delete non-main route tables + for rt in $(aws ec2 describe-route-tables \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "RouteTables[?Associations[?Main==\`false\`] || !Associations].RouteTableId" \ + --output text 2>/dev/null || true); do + log " Deleting route table: $rt" + aws ec2 delete-route-table --route-table-id "$rt" --region "$REGION" || true + done + + # Revoke all security group rules, then delete non-default security groups + for sg in $(aws ec2 describe-security-groups \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "SecurityGroups[?GroupName!='default'].GroupId" \ + --output text 2>/dev/null || true); do + + # Revoke ingress rules + INGRESS=$(aws ec2 describe-security-group-rules \ + --region "$REGION" \ + --filters "Name=group-id,Values=$sg" \ + --query "SecurityGroupRules[?!IsEgress].SecurityGroupRuleId" \ + --output text 2>/dev/null || true) + if [ -n "$INGRESS" ]; then + aws ec2 revoke-security-group-ingress \ + --group-id "$sg" \ + --security-group-rule-ids $INGRESS \ + --region "$REGION" || true + fi + + # Revoke egress rules + EGRESS=$(aws ec2 describe-security-group-rules \ + --region "$REGION" \ + --filters "Name=group-id,Values=$sg" \ + --query "SecurityGroupRules[?IsEgress].SecurityGroupRuleId" \ + --output text 2>/dev/null || true) + if [ -n "$EGRESS" ]; then + aws ec2 revoke-security-group-egress \ + --group-id "$sg" \ + --security-group-rule-ids $EGRESS \ + --region "$REGION" || true + fi + done + + # Now delete the security groups (after rules are gone) + for sg in $(aws ec2 describe-security-groups \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "SecurityGroups[?GroupName!='default'].GroupId" \ + --output text 2>/dev/null || true); do + log " Deleting security group: $sg" + aws ec2 delete-security-group --group-id "$sg" --region "$REGION" || true + done + + log " Deleting VPC: $vpc" + aws ec2 delete-vpc --vpc-id "$vpc" --region "$REGION" || true +done + +##################### +# IAM (sandbox roles and instance profiles) +##################### +log "Deleting sandbox IAM resources..." +for profile in $(aws iam list-instance-profiles \ + --query "InstanceProfiles[?starts_with(InstanceProfileName,'finishline-sandbox-')].InstanceProfileName" \ + --output text 2>/dev/null || true); do + for role in $(aws iam get-instance-profile \ + --instance-profile-name "$profile" \ + --query "InstanceProfile.Roles[].RoleName" \ + --output text 2>/dev/null || true); do + aws iam remove-role-from-instance-profile \ + --instance-profile-name "$profile" --role-name "$role" || true + done + log " Deleting instance profile: $profile" + aws iam delete-instance-profile --instance-profile-name "$profile" || true +done + +for role in $(aws iam list-roles \ + --query "Roles[?starts_with(RoleName,'finishline-sandbox-')].RoleName" \ + --output text 2>/dev/null || true); do + for policy in $(aws iam list-role-policies --role-name "$role" --query PolicyNames[] --output text 2>/dev/null || true); do + aws iam delete-role-policy --role-name "$role" --policy-name "$policy" || true + done + for arn in $(aws iam list-attached-role-policies --role-name "$role" --query "AttachedPolicies[].PolicyArn" --output text 2>/dev/null || true); do + aws iam detach-role-policy --role-name "$role" --policy-arn "$arn" || true + done + log " Deleting IAM role: $role" + aws iam delete-role --role-name "$role" || true +done + +log "Cleanup complete." diff --git a/src/backend/index.ts b/src/backend/index.ts index 0df1277a2d..ef668c80cc 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -33,9 +33,12 @@ const app = express(); const port = process.env.PORT || 3001; const isProd = process.env.NODE_ENV === 'production'; +// Sandbox's frontend is a real production build, so it always uses the real Google +// login flow (never the dev login) and needs the same auth/CORS handling as prod. +const usesRealGoogleAuth = isProd || (process.env.NODE_ENV as string) === 'sandbox'; // cors options -const allowedHeaders = isProd ? prodHeaders : '*'; +const allowedHeaders = usesRealGoogleAuth ? prodHeaders : '*'; // Build list of allowed origins const allowedOrigins = [ @@ -85,7 +88,7 @@ app.use(express.json()); app.use(cors(options)); // ensure each request is authorized using JWT -app.use(isProd ? requireJwtProd : requireJwtDev); +app.use(usesRealGoogleAuth ? requireJwtProd : requireJwtDev); // get user and organization app.use(getUserAndOrganization);