diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml new file mode 100644 index 0000000..5d2e10d --- /dev/null +++ b/.github/workflows/bootstrap.yml @@ -0,0 +1,274 @@ +name: Bootstrap GCP Infrastructure + +# One-time workflow to create foundational GCP resources that Terraform and +# CI/CD depend on. After successful bootstrap, WIF replaces the service +# account key for all subsequent workflows. +# +# Prerequisites (manual, one-time): +# 1. Create a GCP project and enable billing +# 2. Create a service account with Owner role +# 3. Create a JSON key and add it as GitHub secret: GCP_SERVICE_ACCOUNT +# +# After bootstrap completes, GCP_SERVICE_ACCOUNT is overwritten with the +# WIF service account email — the JSON key is no longer needed. + +on: + workflow_dispatch: + inputs: + project_id: + description: "GCP project ID" + required: true + region: + description: "GCP region" + required: true + default: "us-central1" + google_sign_in_client_id: + description: "Google OAuth client ID (leave empty on first run, set on phase 2)" + required: false + default: "" + +permissions: + contents: read + +jobs: + bootstrap: + name: Bootstrap GCP + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@62cf5bd3e4211a0a0b51f2c6d6a37129d828611d # v2 + with: + credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@6189d56e4096ee891640bb02ac264be376592d6a # v2 + + - name: Enable required APIs + run: | + gcloud services enable \ + cloudresourcemanager.googleapis.com \ + iam.googleapis.com \ + iamcredentials.googleapis.com \ + storage.googleapis.com \ + artifactregistry.googleapis.com \ + run.googleapis.com \ + sqladmin.googleapis.com \ + secretmanager.googleapis.com \ + firebase.googleapis.com \ + identitytoolkit.googleapis.com \ + pubsub.googleapis.com \ + aiplatform.googleapis.com \ + --project="${{ inputs.project_id }}" + + - name: Create Terraform state bucket + run: | + BUCKET="broodly-terraform-state" + if gcloud storage buckets describe "gs://${BUCKET}" --project="${{ inputs.project_id }}" > /dev/null 2>&1; then + echo "State bucket gs://${BUCKET} already exists" + else + gcloud storage buckets create "gs://${BUCKET}" \ + --project="${{ inputs.project_id }}" \ + --location="${{ inputs.region }}" \ + --uniform-bucket-level-access \ + --public-access-prevention + echo "Created state bucket gs://${BUCKET}" + fi + + # Enable versioning for state file safety + gcloud storage buckets update "gs://${BUCKET}" --versioning + + - name: Create Artifact Registry repository + run: | + REPO="broodly" + if gcloud artifacts repositories describe "${REPO}" \ + --project="${{ inputs.project_id }}" \ + --location="${{ inputs.region }}" > /dev/null 2>&1; then + echo "Artifact Registry repo ${REPO} already exists" + else + gcloud artifacts repositories create "${REPO}" \ + --project="${{ inputs.project_id }}" \ + --location="${{ inputs.region }}" \ + --repository-format=docker \ + --description="Broodly container images" + echo "Created Artifact Registry repo ${REPO}" + fi + + - name: Set up Workload Identity Federation + id: wif + run: | + PROJECT_ID="${{ inputs.project_id }}" + PROJECT_NUMBER=$(gcloud projects describe "${PROJECT_ID}" --format="value(projectNumber)") + POOL_NAME="github-actions" + PROVIDER_NAME="github" + SA_NAME="github-actions-ci" + SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" + REPO="${{ github.repository }}" + + # Create CI service account (if not exists) + if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${PROJECT_ID}" > /dev/null 2>&1; then + echo "Service account ${SA_EMAIL} already exists" + else + gcloud iam service-accounts create "${SA_NAME}" \ + --project="${PROJECT_ID}" \ + --display-name="GitHub Actions CI/CD" + fi + + # Grant necessary roles to CI service account + for role in \ + roles/run.admin \ + roles/artifactregistry.writer \ + roles/iam.serviceAccountUser \ + roles/storage.admin \ + roles/firebase.admin \ + roles/secretmanager.admin \ + roles/cloudsql.admin \ + roles/pubsub.admin; do + gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="${role}" \ + --condition=None \ + --quiet 2>/dev/null || true + done + + # Create WIF pool (if not exists) + if gcloud iam workload-identity-pools describe "${POOL_NAME}" \ + --project="${PROJECT_ID}" \ + --location="global" > /dev/null 2>&1; then + echo "WIF pool ${POOL_NAME} already exists" + else + gcloud iam workload-identity-pools create "${POOL_NAME}" \ + --project="${PROJECT_ID}" \ + --location="global" \ + --display-name="GitHub Actions" + fi + + # Create WIF provider (if not exists) + if gcloud iam workload-identity-pools providers describe "${PROVIDER_NAME}" \ + --project="${PROJECT_ID}" \ + --location="global" \ + --workload-identity-pool="${POOL_NAME}" > /dev/null 2>&1; then + echo "WIF provider ${PROVIDER_NAME} already exists" + else + gcloud iam workload-identity-pools providers create-oidc "${PROVIDER_NAME}" \ + --project="${PROJECT_ID}" \ + --location="global" \ + --workload-identity-pool="${POOL_NAME}" \ + --display-name="GitHub" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \ + --issuer-uri="https://token.actions.githubusercontent.com" + fi + + # Allow GitHub repo to impersonate the CI service account + WIF_PROVIDER="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_NAME}/providers/${PROVIDER_NAME}" + gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \ + --project="${PROJECT_ID}" \ + --role="roles/iam.workloadIdentityUser" \ + --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_NAME}/attribute.repository/${REPO}" \ + --quiet + + # Output values for GitHub secrets + echo "wif_provider=${WIF_PROVIDER}" >> "$GITHUB_OUTPUT" + echo "service_account=${SA_EMAIL}" >> "$GITHUB_OUTPUT" + echo "" + echo "============================================" + echo " Workload Identity Federation configured" + echo "============================================" + echo " Provider: ${WIF_PROVIDER}" + echo " Service Account: ${SA_EMAIL}" + echo "" + + - name: Set GitHub secrets via gh CLI + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Set the WIF secrets that deploy-api and terraform jobs need. + # GCP_SERVICE_ACCOUNT is overwritten: JSON key → WIF SA email. + gh secret set GCP_PROJECT_ID --body "${{ inputs.project_id }}" + gh secret set GCP_WORKLOAD_IDENTITY_PROVIDER --body "${{ steps.wif.outputs.wif_provider }}" + gh secret set GCP_SERVICE_ACCOUNT --body "${{ steps.wif.outputs.service_account }}" + + echo "" + echo "============================================" + echo " GitHub secrets configured" + echo "============================================" + echo " GCP_PROJECT_ID" + echo " GCP_WORKLOAD_IDENTITY_PROVIDER" + echo " GCP_SERVICE_ACCOUNT (overwritten with WIF SA email)" + echo "" + echo "All future workflows use Workload Identity Federation." + echo "The original JSON key in GCP_SERVICE_ACCOUNT has been replaced." + + - name: Run Terraform init and apply + run: | + cd infra/terraform/environments/dev + + # Write tfvars — includes google_sign_in_client_id when provided + { + printf 'project_id = "%s"\n' "${{ inputs.project_id }}" + printf 'region = "%s"\n' "${{ inputs.region }}" + printf 'environment = "dev"\n' + if [ -n "${{ inputs.google_sign_in_client_id }}" ]; then + printf 'google_sign_in_client_id = "%s"\n' "${{ inputs.google_sign_in_client_id }}" + fi + } > terraform.tfvars + + terraform init + terraform apply -auto-approve + + - name: Export Firebase config to GitHub secrets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd infra/terraform/environments/dev + + set_secret() { + local tf_output="$1" + local secret_name="$2" + local value + value=$(terraform output -raw "${tf_output}" 2>/dev/null) || value="" + if [ -n "${value}" ]; then + gh secret set "${secret_name}" --body "${value}" + echo " SET ${secret_name}" + else + echo " SKIP ${secret_name} (empty)" + fi + } + + echo "Setting Firebase secrets..." + set_secret "firebase_api_key" "EXPO_PUBLIC_FIREBASE_API_KEY" + set_secret "firebase_auth_domain" "EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN" + set_secret "firebase_project_id" "EXPO_PUBLIC_FIREBASE_PROJECT_ID" + set_secret "firebase_storage_bucket" "EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET" + set_secret "firebase_messaging_sender_id" "EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID" + set_secret "firebase_app_id" "EXPO_PUBLIC_FIREBASE_APP_ID" + + # Cloud Run URL for API + set_secret "cloud_run_url" "EXPO_PUBLIC_API_URL" + + echo "" + echo "Bootstrap complete! All secrets are configured." + + - name: Summary + run: | + echo "## Bootstrap Complete" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### Resources Created" >> "$GITHUB_STEP_SUMMARY" + echo "- GCS state bucket: \`broodly-terraform-state\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Artifact Registry: \`broodly\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Workload Identity Federation pool + provider" >> "$GITHUB_STEP_SUMMARY" + echo "- CI service account: \`github-actions-ci@${{ inputs.project_id }}.iam.gserviceaccount.com\`" >> "$GITHUB_STEP_SUMMARY" + echo "- All Terraform-managed resources (Cloud SQL, Firebase, Cloud Run, etc.)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### GitHub Secrets Set" >> "$GITHUB_STEP_SUMMARY" + echo "- \`GCP_PROJECT_ID\`, \`GCP_WORKLOAD_IDENTITY_PROVIDER\`, \`GCP_SERVICE_ACCOUNT\`" >> "$GITHUB_STEP_SUMMARY" + echo "- \`EXPO_PUBLIC_FIREBASE_*\` (6 secrets)" >> "$GITHUB_STEP_SUMMARY" + echo "- \`EXPO_PUBLIC_API_URL\`" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### Next Steps" >> "$GITHUB_STEP_SUMMARY" + echo "1. **Delete the bootstrap service account** in GCP Console (no longer needed)" >> "$GITHUB_STEP_SUMMARY" + echo "2. Enable Google Sign-In in Firebase Console → Authentication → Sign-in method" >> "$GITHUB_STEP_SUMMARY" + echo "3. Retrieve the auto-created OAuth client ID from GCP Console → Credentials" >> "$GITHUB_STEP_SUMMARY" + echo "4. Re-run this workflow with \`google_sign_in_client_id\` to complete auth setup" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 047a8be..ec70a68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,26 +156,17 @@ jobs: run: docker build -f apps/api/Dockerfile -t broodly-api:ci apps/api/ # --------------------------------------------------------------------------- - # Push API image to Artifact Registry (main branch only) + # Deploy API to Cloud Run (main branch only) # --------------------------------------------------------------------------- - # The Cloud Run service is managed by Terraform (infra/terraform/modules/cloud-run/). - # This job builds and pushes the image; Terraform references the :latest tag. - # To deploy a new version: push to main (image pushed here), then run terraform apply. - # - # Required GitHub Secrets: - # GCP_PROJECT_ID — GCP project ID (e.g. broodly-491920) - # GCP_WORKLOAD_IDENTITY_PROVIDER — Full provider resource name: - # projects//locations/global/workloadIdentityPools//providers/ - # GCP_SERVICE_ACCOUNT — SA email for WIF (e.g. broodly-ci@broodly-491920.iam.gserviceaccount.com) - push-api-image: - name: Push API Image + deploy-api: + name: Deploy API runs-on: ubuntu-latest timeout-minutes: 15 if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [go] permissions: contents: read - id-token: write + id-token: write # Required for Workload Identity Federation steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -191,7 +182,85 @@ jobs: - name: Build and push container image working-directory: . run: | - IMAGE_BASE="us-central1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/broodly/api" - docker build -f apps/api/Dockerfile -t "${IMAGE_BASE}:${{ github.sha }}" -t "${IMAGE_BASE}:latest" apps/api/ - docker push "${IMAGE_BASE}:${{ github.sha }}" - docker push "${IMAGE_BASE}:latest" + IMAGE="us-central1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/broodly/api" + docker build -f apps/api/Dockerfile \ + -t "${IMAGE}:${{ github.sha }}" \ + -t "${IMAGE}:latest" \ + apps/api/ + docker push "${IMAGE}:${{ github.sha }}" + docker push "${IMAGE}:latest" + + # Service name and region must match Terraform cloud-run module config + # in infra/terraform/environments/dev/main.tf + - name: Deploy to Cloud Run + env: + CLOUD_RUN_SERVICE: broodly-api-dev + CLOUD_RUN_REGION: us-central1 + run: | + gcloud run deploy "${CLOUD_RUN_SERVICE}" \ + --image "us-central1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/broodly/api:${{ github.sha }}" \ + --region "${CLOUD_RUN_REGION}" \ + --project "${{ secrets.GCP_PROJECT_ID }}" \ + --quiet + + # --------------------------------------------------------------------------- + # Mobile Web Build (main branch only) + # --------------------------------------------------------------------------- + mobile-build: + name: Mobile Build + runs-on: ubuntu-latest + timeout-minutes: 20 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [typescript] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Validate required secrets + env: + FIREBASE_API_KEY: ${{ secrets.EXPO_PUBLIC_FIREBASE_API_KEY }} + FIREBASE_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_FIREBASE_PROJECT_ID }} + API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} + run: | + missing=() + [ -z "${FIREBASE_API_KEY}" ] && missing+=("EXPO_PUBLIC_FIREBASE_API_KEY") + [ -z "${FIREBASE_PROJECT_ID}" ] && missing+=("EXPO_PUBLIC_FIREBASE_PROJECT_ID") + [ -z "${API_URL}" ] && missing+=("EXPO_PUBLIC_API_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing required secrets: ${missing[*]}" + echo "Run the Bootstrap workflow or scripts/bootstrap-secrets.sh first." + exit 1 + fi + + - name: Generate .env from secrets + working-directory: apps/mobile + run: | + { + echo "EXPO_PUBLIC_FIREBASE_USE_EMULATOR=false" + echo "EXPO_PUBLIC_FIREBASE_API_KEY=${{ secrets.EXPO_PUBLIC_FIREBASE_API_KEY }}" + echo "EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN }}" + echo "EXPO_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.EXPO_PUBLIC_FIREBASE_PROJECT_ID }}" + echo "EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET }}" + echo "EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}" + echo "EXPO_PUBLIC_FIREBASE_APP_ID=${{ secrets.EXPO_PUBLIC_FIREBASE_APP_ID }}" + echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" + } > .env + + - name: Expo web export + working-directory: apps/mobile + run: npx expo export --platform web + + - name: Upload web build artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: web-build + path: apps/mobile/dist/ + retention-days: 14 diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..85228bc --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,99 @@ +name: Terraform + +# Runs Terraform plan on PRs and apply on merge to main. +# Uses Workload Identity Federation (set up by bootstrap.yml). + +on: + push: + branches: [main] + paths: + - "infra/terraform/**" + pull_request: + branches: [main] + paths: + - "infra/terraform/**" + +permissions: + contents: read + id-token: write # Required for WIF + pull-requests: write # For plan comments + +concurrency: + group: terraform-${{ github.ref }} + cancel-in-progress: false # Never cancel in-progress applies + +jobs: + terraform: + name: Terraform + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TF_VAR_project_id: ${{ secrets.GCP_PROJECT_ID }} + defaults: + run: + working-directory: infra/terraform/environments/dev + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@62cf5bd3e4211a0a0b51f2c6d6a37129d828611d # v2 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + + - name: Set up Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + with: + terraform_version: "~> 1.5" + + - name: Terraform Init + run: terraform init + + - name: Terraform Validate + run: terraform validate + + - name: Terraform Plan + id: plan + run: terraform plan -no-color -out=tfplan + continue-on-error: true + + - name: Comment plan on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + env: + TERRAFORM_PLAN: ${{ steps.plan.outputs.stdout }} + PLAN_OUTCOME: ${{ steps.plan.outcome }} + with: + script: | + const plan = (process.env.TERRAFORM_PLAN || '').substring(0, 60000); + const body = `### Terraform Plan\n\`\`\`\n${plan}\n\`\`\`\n*Plan: ${process.env.PLAN_OUTCOME}*`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body?.includes('### Terraform Plan')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Terraform Plan Status + if: steps.plan.outcome == 'failure' + run: exit 1 + + - name: Terraform Apply + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: terraform apply -auto-approve tfplan diff --git a/.gitignore b/.gitignore index 4fdd01c..65bd955 100644 --- a/.gitignore +++ b/.gitignore @@ -21,12 +21,15 @@ apps/mobile/web-build/ # Go apps/api/bin/ apps/api/tmp/ +apps/api/server *.exe *.test *.out # Terraform **/.terraform/ +# Module-level lock files are not used; only root environment lock files matter +infra/terraform/modules/**/.terraform.lock.hcl *.tfstate *.tfstate.* *.tfplan @@ -54,6 +57,7 @@ terraform.rc # Test coverage/ *.lcov +test-results/ # Misc *.log diff --git a/README.md b/README.md index bd32d8f..9420611 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ pnpm --filter mobile start cd apps/api && go run cmd/server/main.go ``` +## Infrastructure Setup + +GCP infrastructure is fully automated via GitHub Actions. Two manual steps are required for initial setup: + +1. Create a temporary GCP service account key +2. Add it as a GitHub secret + +Then run the bootstrap workflow — it provisions all cloud resources and configures CI/CD automatically. + +See **[GCP Bootstrap Runbook](docs/runbooks/gcp-bootstrap.md)** for step-by-step instructions. + ## Monorepo Structure ``` diff --git a/docs/runbooks/gcp-bootstrap.md b/docs/runbooks/gcp-bootstrap.md new file mode 100644 index 0000000..64946c7 --- /dev/null +++ b/docs/runbooks/gcp-bootstrap.md @@ -0,0 +1,172 @@ +# GCP Bootstrap — One-Time Setup + +This runbook covers the preparation steps and the automated bootstrap workflow that provisions all GCP infrastructure. + +## Overview + +The bootstrap workflow (`bootstrap.yml`) creates all GCP resources automatically: +- Terraform state bucket (GCS) +- Artifact Registry (Docker images) +- Workload Identity Federation (keyless CI/CD auth) +- CI service account with scoped IAM roles +- All Terraform-managed resources (Cloud SQL, Firebase, Cloud Run, Pub/Sub, Storage) +- GitHub Actions secrets populated from Terraform outputs + +**Two preparation steps** are required before running the workflow. After the workflow completes, a cleanup step removes the temporary credentials. + +**Prerequisites:** A GCP service account key stored as GitHub secret `GCP_SERVICE_ACCOUNT`. If you already have this, skip to [Run the Bootstrap Workflow](#run-the-bootstrap-workflow). + +--- + +## Preparation Step 1: Create a GCP Service Account Key (if not already done) + +This service account key lets the bootstrap workflow authenticate to GCP. After bootstrap, it is automatically replaced with a Workload Identity Federation service account email — the JSON key is no longer used. + +### 1. Create a GCP project + +1. Go to [Google Cloud Console](https://console.cloud.google.com) +2. Click **Select a project** → **New Project** +3. Name it (e.g., `broodly-dev`) and note the **Project ID** +4. Ensure billing is enabled: **Billing** → **Link a billing account** + +### 2. Create the bootstrap service account + +1. Go to **IAM & Admin** → **[Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts)** +2. Click **+ Create Service Account** + - **Name:** `bootstrap` + - **ID:** `bootstrap` +3. Click **Create and Continue** +4. Under **Grant this service account access to project**, add the role: + - **Basic** → **Owner** + > Owner is needed because bootstrap creates IAM bindings, enables APIs, and provisions multiple resource types. +5. Click **Done** + +### 3. Create and download a JSON key + +1. Click on the `bootstrap` service account +2. Go to the **Keys** tab +3. Click **Add Key** → **Create new key** +4. Select **JSON** → **Create** +5. A `.json` file downloads + +## Preparation Step 2: Add the key as a GitHub secret + +1. Go to your GitHub repo → **Settings** → **Secrets and variables** → **Actions** +2. Click **New repository secret** +3. **Name:** `GCP_SERVICE_ACCOUNT` +4. **Value:** Paste the **entire contents** of the downloaded JSON key file +5. Click **Add secret** + +--- + +## Run the Bootstrap Workflow + +1. Go to your GitHub repo → **Actions** → **Bootstrap GCP Infrastructure** +2. Click **Run workflow** +3. Fill in: + - **GCP project ID:** Your project ID (e.g., `broodly-dev`) + - **GCP region:** `us-central1` (default) +4. Click **Run workflow** + +The workflow takes ~10-15 minutes and will: +- Enable all required GCP APIs +- Create the Terraform state bucket with versioning +- Create the Artifact Registry for Docker images +- Set up Workload Identity Federation (keyless auth for all future CI/CD) +- Create and configure the CI service account with scoped roles +- Run `terraform apply` to create all infrastructure +- Populate all GitHub Actions secrets from Terraform outputs +- **Overwrite `GCP_SERVICE_ACCOUNT`** with the WIF service account email (replacing the JSON key) + +### Monitor progress + +Watch the workflow run in the **Actions** tab. Each step logs what it creates. On success, you'll see a summary of all resources and secrets. + +--- + +## Post-Bootstrap Cleanup + +After the workflow succeeds, the bootstrap service account is no longer needed — `GCP_SERVICE_ACCOUNT` now contains the WIF service account email, and all CI/CD uses Workload Identity Federation. + +1. **Delete the bootstrap service account in GCP:** + - GCP Console → **IAM & Admin** → **Service Accounts** + - Find `bootstrap@.iam.gserviceaccount.com` + - Click **⋮** → **Delete** + +2. **Delete the downloaded JSON file** from your computer + +> **Why delete?** The bootstrap SA has Owner permissions. Leaving it around is a security risk. WIF generates short-lived tokens scoped to each workflow run. + +--- + +## What Gets Created + +### GCP Resources + +| Resource | Name | Purpose | +|----------|------|---------| +| GCS Bucket | `broodly-terraform-state` | Terraform remote state (versioned) | +| Artifact Registry | `broodly` | Docker container images | +| WIF Pool | `github-actions` | OIDC identity pool for GitHub | +| WIF Provider | `github` | Maps GitHub OIDC tokens to GCP | +| Service Account | `github-actions-ci` | CI/CD identity with scoped roles | +| Cloud SQL | `broodly-db-dev` | PostgreSQL 16 database | +| Cloud Run | `broodly-api-dev` | API server (scale-to-zero) | +| Firebase | `Broodly Web (dev)` | Auth, web app config | +| Pub/Sub | Topics + subscriptions | Async event processing | +| Cloud Storage | `broodly-media-dev` | Media uploads | +| Secret Manager | 3 secrets | DB password, connection string, Firebase SA | + +### GitHub Secrets (auto-populated) + +| Secret | Source | Notes | +|--------|--------|-------| +| `GCP_PROJECT_ID` | Workflow input | | +| `GCP_WORKLOAD_IDENTITY_PROVIDER` | Created WIF provider | | +| `GCP_SERVICE_ACCOUNT` | Created CI service account | Overwrites the JSON key with WIF SA email | +| `EXPO_PUBLIC_FIREBASE_API_KEY` | Terraform output | | +| `EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN` | Terraform output | | +| `EXPO_PUBLIC_FIREBASE_PROJECT_ID` | Terraform output | | +| `EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET` | Terraform output | | +| `EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID` | Terraform output | | +| `EXPO_PUBLIC_FIREBASE_APP_ID` | Terraform output | | +| `EXPO_PUBLIC_API_URL` | Cloud Run service URL | | + +--- + +## After Bootstrap: What's Automated + +Once bootstrap completes, these workflows run automatically: + +| Trigger | Workflow | What it does | +|---------|----------|-------------| +| PR to `main` with `infra/terraform/**` changes | `terraform.yml` | Runs `terraform plan`, comments on PR | +| Merge to `main` with `infra/terraform/**` changes | `terraform.yml` | Runs `terraform apply` | +| Merge to `main` | `ci.yml` → `deploy-api` | Builds API image, deploys to Cloud Run | +| Merge to `main` | `ci.yml` → `mobile-build` | Exports Expo web build with real Firebase config | + +--- + +## Troubleshooting + +### "Permission denied" during bootstrap + +The service account in `GCP_SERVICE_ACCOUNT` needs Owner role. Verify in GCP Console → **IAM & Admin** → **IAM** that the service account has the Owner role. + +### "Billing account not linked" + +Some APIs (Cloud SQL, Cloud Run) require billing. Go to GCP Console → **Billing** and link a billing account to your project. + +### Bootstrap succeeded but secrets are empty + +Check the Terraform outputs: +```bash +cd infra/terraform/environments/dev +terraform output +``` + +If Firebase outputs are empty, the Firebase web app may not have been created yet. Re-run the bootstrap workflow. + +### Re-running bootstrap + +The workflow is idempotent — it checks for existing resources before creating them. Safe to re-run if something failed partway through. Note that `GCP_SERVICE_ACCOUNT` is overwritten on each run (JSON key → WIF email), so the workflow only works with the JSON key on the first run. Subsequent runs use WIF. diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/dev/main.tf index a9c9561..c25f748 100644 --- a/infra/terraform/environments/dev/main.tf +++ b/infra/terraform/environments/dev/main.tf @@ -175,9 +175,16 @@ module "pubsub" { module "firebase" { source = "../../modules/firebase" - project_id = var.project_id - environment = var.environment - display_name = "Broodly Web" + project_id = var.project_id + environment = var.environment + display_name = "Broodly Web" + authorized_domains = [] # localhost is auto-included for dev by the module + + # Google Sign-In OAuth client ID/secret — leave empty on first apply. + # After Firebase auto-creates the OAuth client, retrieve it from + # GCP Console → APIs & Services → Credentials and re-apply. + google_sign_in_client_id = var.google_sign_in_client_id + google_sign_in_client_secret = var.google_sign_in_client_secret } module "cloud_run" { @@ -186,11 +193,12 @@ module "cloud_run" { service_name = "broodly-api-${var.environment}" project_id = var.project_id region = var.region + environment = var.environment image = "us-central1-docker.pkg.dev/${var.project_id}/broodly/api:latest" service_account_email = google_service_account.api.email db_connection_secret = google_secret_manager_secret.db_connection_string.secret_id - cors_origin = "https://broodly-${var.environment}.web.app" - min_instances = 0 - max_instances = 5 - allow_unauthenticated = true # API validates Firebase tokens internally + cors_origin = "https://broodly-${var.environment}.web.app" + min_instances = 0 + max_instances = 5 + allow_unauthenticated = true # API validates Firebase tokens internally } diff --git a/infra/terraform/environments/dev/outputs.tf b/infra/terraform/environments/dev/outputs.tf index 40b517c..046ea76 100644 --- a/infra/terraform/environments/dev/outputs.tf +++ b/infra/terraform/environments/dev/outputs.tf @@ -48,11 +48,6 @@ output "worker_service_account_email" { } # Firebase outputs -output "firebase_web_app_id" { - description = "Firebase web app ID" - value = module.firebase.web_app_id -} - output "firebase_api_key" { description = "Firebase API key for web app" value = module.firebase.api_key @@ -69,6 +64,21 @@ output "firebase_project_id" { value = module.firebase.project_id } +output "firebase_storage_bucket" { + description = "Firebase storage bucket" + value = module.firebase.storage_bucket +} + +output "firebase_messaging_sender_id" { + description = "Firebase messaging sender ID" + value = module.firebase.messaging_sender_id +} + +output "firebase_app_id" { + description = "Firebase app ID" + value = module.firebase.app_id +} + # Cloud Run outputs output "cloud_run_service_url" { description = "Cloud Run API service URL" diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/dev/variables.tf index 297ecda..2bd46b7 100644 --- a/infra/terraform/environments/dev/variables.tf +++ b/infra/terraform/environments/dev/variables.tf @@ -14,3 +14,16 @@ variable "environment" { type = string default = "dev" } + +variable "google_sign_in_client_id" { + description = "OAuth 2.0 client ID for Google Sign-In (from GCP Console). Leave empty on first apply." + type = string + default = "" +} + +variable "google_sign_in_client_secret" { + description = "OAuth 2.0 client secret for Google Sign-In" + type = string + default = "" + sensitive = true +} diff --git a/infra/terraform/modules/cloud-run/main.tf b/infra/terraform/modules/cloud-run/main.tf index 78ed81d..2f81610 100644 --- a/infra/terraform/modules/cloud-run/main.tf +++ b/infra/terraform/modules/cloud-run/main.tf @@ -1,7 +1,20 @@ +# ----------------------------------------------------------------------------- +# Cloud Run Service +# ----------------------------------------------------------------------------- +# Deploys the API container to Cloud Run. CI pushes new revisions via +# `gcloud run deploy`; Terraform manages the service definition, scaling, +# IAM, and environment configuration. +# ----------------------------------------------------------------------------- + resource "google_cloud_run_v2_service" "api" { name = var.service_name location = var.region + labels = { + environment = var.environment + managed-by = "terraform" + } + template { service_account = var.service_account_email @@ -50,12 +63,18 @@ resource "google_cloud_run_v2_service" "api" { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" percent = 100 } + + lifecycle { + # CI deploys new images via gcloud; don't revert to the Terraform-specified tag + ignore_changes = [template[0].containers[0].image] + } } # SECURITY: Allow unauthenticated HTTP access to Cloud Run. -# This is intentional — the Go API validates Firebase ID tokens in its own -# auth middleware (internal/auth/middleware.go). Cloud Run's built-in IAM -# auth is bypassed because mobile/web clients send Bearer tokens directly. +# Only enable this for services that enforce authentication at the application +# layer (e.g., validating Firebase ID tokens on every request) or that are +# intentionally public. Cloud Run's built-in IAM auth is bypassed so that +# mobile/web clients can send Bearer tokens directly to the service. # Removing this binding would block all client traffic. resource "google_cloud_run_v2_service_iam_member" "public" { # NOSONAR — intentional, see comment above count = var.allow_unauthenticated ? 1 : 0 diff --git a/infra/terraform/modules/cloud-run/variables.tf b/infra/terraform/modules/cloud-run/variables.tf index 646d63d..523219c 100644 --- a/infra/terraform/modules/cloud-run/variables.tf +++ b/infra/terraform/modules/cloud-run/variables.tf @@ -13,6 +13,11 @@ variable "region" { type = string } +variable "environment" { + description = "Environment name (dev, staging, prod) — used for resource labels" + type = string +} + variable "image" { description = "Container image to deploy (full Artifact Registry path)" type = string diff --git a/infra/terraform/modules/firebase/main.tf b/infra/terraform/modules/firebase/main.tf new file mode 100644 index 0000000..0b43dfe --- /dev/null +++ b/infra/terraform/modules/firebase/main.tf @@ -0,0 +1,110 @@ +# ----------------------------------------------------------------------------- +# Firebase Project & Web App + Identity Platform Auth +# ----------------------------------------------------------------------------- +# Provisions Firebase on an existing GCP project, creates a web app, and +# configures Identity Platform for authentication (Google Sign-In). +# +# Apple Sign-In requires credentials from Apple Developer Console — enable it +# manually in Firebase Console → Authentication → Sign-in method after the +# initial apply. +# ----------------------------------------------------------------------------- + +# Enable required APIs +resource "google_project_service" "identity_toolkit" { + provider = google-beta + project = var.project_id + service = "identitytoolkit.googleapis.com" + + disable_on_destroy = false +} + +resource "google_project_service" "firebase" { + provider = google-beta + project = var.project_id + service = "firebase.googleapis.com" + + disable_on_destroy = false +} + +# Firebase project +resource "google_firebase_project" "default" { + provider = google-beta + project = var.project_id + + depends_on = [google_project_service.firebase] +} + +# Firebase web app +resource "google_firebase_web_app" "default" { + provider = google-beta + project = var.project_id + display_name = "${var.display_name} (${var.environment})" + + depends_on = [google_firebase_project.default] +} + +# Web app config (provides apiKey, authDomain, etc.) +data "google_firebase_web_app_config" "default" { + provider = google-beta + project = var.project_id + web_app_id = google_firebase_web_app.default.app_id + + depends_on = [google_firebase_web_app.default] +} + +# ----------------------------------------------------------------------------- +# Identity Platform — Auth Configuration +# ----------------------------------------------------------------------------- +# Configures authorized domains and sign-in settings. Google Sign-In provider +# is conditionally enabled when client_id is provided. +# +# Two-phase setup: +# 1. First apply: Creates Firebase project + Identity Platform config. +# Firebase auto-creates an OAuth client for Google Sign-In. +# 2. Retrieve the auto-created OAuth client ID from GCP Console → +# APIs & Services → Credentials, then re-apply with the client ID. +# ----------------------------------------------------------------------------- + +resource "google_identity_platform_config" "default" { + provider = google-beta + project = var.project_id + + sign_in { + allow_duplicate_emails = false + } + + authorized_domains = distinct(concat( + [ + "${var.project_id}.firebaseapp.com", + "${var.project_id}.web.app", + ], + var.environment == "dev" ? ["localhost"] : [], + var.authorized_domains, + )) + + depends_on = [ + google_project_service.identity_toolkit, + google_firebase_project.default, + ] +} + +# Google Sign-In provider (conditional — requires OAuth client ID) +resource "google_identity_platform_default_supported_idp_config" "google" { + count = var.google_sign_in_client_id != "" ? 1 : 0 + + provider = google-beta + project = var.project_id + idp_id = "google.com" + client_id = var.google_sign_in_client_id + client_secret = var.google_sign_in_client_secret + enabled = true + + depends_on = [google_identity_platform_config.default] + + lifecycle { + precondition { + condition = var.google_sign_in_client_secret != "" + error_message = "google_sign_in_client_secret must be set when google_sign_in_client_id is non-empty." + } + } +} diff --git a/infra/terraform/modules/firebase/outputs.tf b/infra/terraform/modules/firebase/outputs.tf new file mode 100644 index 0000000..11300c6 --- /dev/null +++ b/infra/terraform/modules/firebase/outputs.tf @@ -0,0 +1,30 @@ +output "api_key" { + description = "Firebase web app API key (for EXPO_PUBLIC_FIREBASE_API_KEY)" + value = data.google_firebase_web_app_config.default.api_key + sensitive = true +} + +output "auth_domain" { + description = "Firebase auth domain (for EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN)" + value = data.google_firebase_web_app_config.default.auth_domain +} + +output "storage_bucket" { + description = "Firebase storage bucket (for EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET)" + value = data.google_firebase_web_app_config.default.storage_bucket +} + +output "messaging_sender_id" { + description = "Firebase messaging sender ID (for EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID)" + value = data.google_firebase_web_app_config.default.messaging_sender_id +} + +output "app_id" { + description = "Firebase app ID (for EXPO_PUBLIC_FIREBASE_APP_ID)" + value = google_firebase_web_app.default.app_id +} + +output "project_id" { + description = "Firebase project ID (for EXPO_PUBLIC_FIREBASE_PROJECT_ID)" + value = var.project_id +} diff --git a/infra/terraform/modules/firebase/variables.tf b/infra/terraform/modules/firebase/variables.tf new file mode 100644 index 0000000..6d5b36e --- /dev/null +++ b/infra/terraform/modules/firebase/variables.tf @@ -0,0 +1,34 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "environment" { + description = "Environment name (dev, staging, prod)" + type = string +} + +variable "display_name" { + description = "Display name for the Firebase web app" + type = string + default = "Broodly Web" +} + +variable "authorized_domains" { + description = "Additional authorized domains for Firebase Auth (e.g., localhost for dev)" + type = list(string) + default = [] +} + +variable "google_sign_in_client_id" { + description = "OAuth 2.0 client ID for Google Sign-In. Leave empty to skip provider setup." + type = string + default = "" +} + +variable "google_sign_in_client_secret" { + description = "OAuth 2.0 client secret for Google Sign-In. Required when google_sign_in_client_id is set." + type = string + default = "" + sensitive = true +} diff --git a/scripts/bootstrap-secrets.sh b/scripts/bootstrap-secrets.sh new file mode 100755 index 0000000..4c920bd --- /dev/null +++ b/scripts/bootstrap-secrets.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# bootstrap-secrets.sh — Populate GitHub Actions secrets from Terraform outputs +# ----------------------------------------------------------------------------- +# Reads Firebase and infrastructure outputs from Terraform and sets them as +# GitHub Actions secrets so CI/CD can build and deploy with real credentials. +# +# Prerequisites: +# - terraform CLI (initialized in the target environment) +# - gh CLI (authenticated with repo admin access) +# +# Usage: +# ./scripts/bootstrap-secrets.sh # Set secrets +# ./scripts/bootstrap-secrets.sh --dry-run # Preview without setting +# ./scripts/bootstrap-secrets.sh --env prod # Target a different environment +# ----------------------------------------------------------------------------- +set -euo pipefail + +REPO="" +ENVIRONMENT="dev" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --env) + if [[ -z "${2:-}" || "${2:-}" == --* ]]; then echo "ERROR: --env requires a value"; exit 1; fi + ENVIRONMENT="$2"; shift 2 ;; + --repo) + if [[ -z "${2:-}" || "${2:-}" == --* ]]; then echo "ERROR: --repo requires a value"; exit 1; fi + REPO="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +REPO_ROOT="$(git rev-parse --show-toplevel)" +TF_DIR="${REPO_ROOT}/infra/terraform/environments/${ENVIRONMENT}" + +# Derive repo from git remote if not specified +if [ -z "${REPO}" ]; then + REPO="${GITHUB_REPOSITORY:-}" + if [ -z "${REPO}" ]; then + REMOTE_URL=$(git -C "${REPO_ROOT}" remote get-url origin 2>/dev/null) || REMOTE_URL="" + # Extract owner/repo from SSH or HTTPS URL + REPO=$(echo "${REMOTE_URL}" | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#') + if [ -z "${REPO}" ]; then + echo "ERROR: Could not determine repository. Use --repo owner/repo" + exit 1 + fi + fi +fi + +if [ ! -d "${TF_DIR}/.terraform" ]; then + echo "ERROR: Terraform not initialized in ${TF_DIR}" + echo "Run: cd ${TF_DIR} && terraform init" + exit 1 +fi + +if ! gh auth status &>/dev/null; then + echo "ERROR: GitHub CLI not authenticated" + echo "Run: gh auth login" + exit 1 +fi + +# Map Terraform output names to GitHub Actions secret names +declare -A SECRET_MAP=( + ["firebase_api_key"]="EXPO_PUBLIC_FIREBASE_API_KEY" + ["firebase_auth_domain"]="EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN" + ["firebase_project_id"]="EXPO_PUBLIC_FIREBASE_PROJECT_ID" + ["firebase_storage_bucket"]="EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET" + ["firebase_messaging_sender_id"]="EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID" + ["firebase_app_id"]="EXPO_PUBLIC_FIREBASE_APP_ID" + ["cloud_run_url"]="EXPO_PUBLIC_API_URL" +) + +echo "Setting GitHub secrets for ${REPO} from ${ENVIRONMENT} Terraform outputs..." +echo "" + +errors=0 +set_count=0 + +for tf_output in "${!SECRET_MAP[@]}"; do + secret_name="${SECRET_MAP[$tf_output]}" + + # Capture both stdout and stderr to distinguish missing outputs from real errors + tf_stderr="" + if value=$(cd "${TF_DIR}" && terraform output -raw "${tf_output}" 2>/tmp/tf_stderr.$$.txt); then + tf_stderr=$(cat /tmp/tf_stderr.$$.txt 2>/dev/null) || true + else + tf_stderr=$(cat /tmp/tf_stderr.$$.txt 2>/dev/null) || true + # Distinguish "output not found" from real failures + if echo "${tf_stderr}" | grep -qiE "not found|no outputs"; then + value="" + else + echo " ERROR ${secret_name} — terraform output failed: ${tf_stderr}" + errors=$((errors + 1)) + rm -f /tmp/tf_stderr.$$.txt + continue + fi + fi + rm -f /tmp/tf_stderr.$$.txt + + if [ -z "${value}" ]; then + echo " SKIP ${secret_name} — terraform output '${tf_output}' is empty" + continue + fi + + if [ "${DRY_RUN}" = true ]; then + echo " [dry-run] Would set ${secret_name}" + else + if gh secret set "${secret_name}" --repo "${REPO}" --body "${value}" 2>/dev/null; then + echo " SET ${secret_name}" + set_count=$((set_count + 1)) + else + echo " ERROR ${secret_name} — failed to set secret" + errors=$((errors + 1)) + fi + fi +done + +echo "" +if [ "${DRY_RUN}" = true ]; then + echo "Dry run complete. No secrets were modified." +else + echo "Done. ${set_count} secret(s) set, ${errors} error(s)." +fi + +if [ "${errors}" -gt 0 ]; then + exit 1 +fi + +echo "" +echo "Note: The following secrets must be set manually (not from Terraform):" +echo " GCP_PROJECT_ID — Your GCP project ID" +echo " GCP_WORKLOAD_IDENTITY_PROVIDER — WIF provider resource name" +echo " GCP_SERVICE_ACCOUNT — CI/CD service account email"