diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b9fda02 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +Dockerfile +.env.local +.env \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d43de6..1f8e3ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: CI +run-name: '[PR] Running pre-merge checks for PR#${{ github.event.number }}' on: push: diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..9d5ba13 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,62 @@ +name: Deploy Staging to Cloud Run +run-name: '[STAGING] Running staging release workflow #${{ github.run_number }}' + +on: + push: + branches: + - dev + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + PROJECT_ID: student-progress-staging + REGION: europe-west3 + REPOSITORY: student-progress-api-repo + SERVICE: student-progress-api + IMAGE_URI: europe-west3-docker.pkg.dev/student-progress-staging/student-progress-api-repo/student-progress-api + +jobs: + deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v3 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64 + push: true + tags: | + ${{ env.IMAGE_URI }}:${{ github.sha }} + ${{ env.IMAGE_URI }}:latest + + - name: Deploy to Cloud Run + uses: google-github-actions/deploy-cloudrun@v3 + with: + service: ${{ env.SERVICE }} + region: ${{ env.REGION }} + image: ${{ env.IMAGE_URI }}:${{ github.sha }} diff --git a/README.md b/README.md index ee951d3..fb323f6 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,20 @@ Cloud Run app → service account IAM DB user Admin tasks → separate admin user / controlled IAM access ``` +**The API runs on Cloud Run and connects to private resources via Direct VPC egress.** + +```txt +Client + ↓ +Cloud Run service + ↓ +Direct VPC egress + ↓ +VPC Network + ↓ +Private resources (Cloud SQL / Redis) +``` + ### API Scope This project intentionally implements a small set of representative endpoints rather than a complete CRUD API. @@ -278,6 +292,28 @@ gcloud run deploy student-progress-api \ Note: Replace `` with the Memorystore private IP. +Subsequent deployments are handled with GitHub Actions. + +### 4. GitHub Actions Authentication (OIDC) + +GitHub Actions authenticates to GCP using Workload Identity Federation (OIDC) instead of long-lived JSON service account keys. + +Terraform provisions: + +- a dedicated GitHub Actions deployer service account +- a Workload Identity Pool +- a GitHub OIDC provider +- IAM bindings allowing the repository to impersonate the deployer service account + +Apply the Terraform configuration. + +Add these Terraform outputs as GitHub Actions repository secrets: + +| **Secret** | **Terraform output** | +| ------------------------------ | -------------------------------------- | +| GCP_WORKLOAD_IDENTITY_PROVIDER | github_workload_identity_provider_name | +| GCP_SERVICE_ACCOUNT | github_deployer_service_account_email | + ## One-off Local Development Setup ### 1. **Install Auth Proxy & update dev script** diff --git a/infra/apis.tf b/infra/apis.tf index baa0e66..5abc026 100644 --- a/infra/apis.tf +++ b/infra/apis.tf @@ -1,11 +1,14 @@ resource "google_project_service" "apis" { for_each = toset([ "artifactregistry.googleapis.com", + "iam.googleapis.com", "iamcredentials.googleapis.com", "run.googleapis.com", # for deploying to Cloud Run "redis.googleapis.com", "secretmanager.googleapis.com", - "sqladmin.googleapis.com" + "sts.googleapis.com", + "sqladmin.googleapis.com", + ]) project = var.project_id diff --git a/infra/iam.tf b/infra/iam.tf index 312394b..951a00e 100644 --- a/infra/iam.tf +++ b/infra/iam.tf @@ -1,3 +1,4 @@ +# Runtime service account used by the application (Cloud Run runtime identity for Cloud SQL, Secret Manager, etc.) resource "google_service_account" "app" { account_id = "student-progress-app-sa" display_name = "Student Progress App" @@ -15,3 +16,68 @@ resource "google_project_iam_member" "app_roles" { member = "serviceAccount:${google_service_account.app.email}" } + + +# GitHub Actions deployer service account +resource "google_service_account" "github_deployer" { + account_id = "github-deployer-sa" + display_name = "GitHub Actions Deployer" +} + +# Permissions for deploying to Cloud Run +resource "google_project_iam_member" "github_deployer_roles" { + + for_each = toset([ + "roles/run.admin", + "roles/artifactregistry.writer", + "roles/iam.serviceAccountUser" + ]) + + project = var.project_id + role = each.value + member = "serviceAccount:${google_service_account.github_deployer.email}" +} + +# Workload Identity Pool used for GitHub Actions authentication +resource "google_iam_workload_identity_pool" "github_actions" { + workload_identity_pool_id = "github-pool" + + display_name = "GitHub Actions Pool" + description = "Workload Identity Pool for GitHub Actions" +} + +# GitHub OIDC provider used to authenticate GitHub Actions to GCP +resource "google_iam_workload_identity_pool_provider" "github_provider" { + workload_identity_pool_id = google_iam_workload_identity_pool.github_actions.workload_identity_pool_id + workload_identity_pool_provider_id = "github-provider" + + display_name = "GitHub Provider" + + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.actor" = "assertion.actor" + "attribute.repository" = "assertion.repository" + "attribute.repository_owner" = "assertion.repository_owner" + "attribute.ref" = "assertion.ref" + } + + attribute_condition = "assertion.repository == '${var.github_org}/${var.github_repo}' && assertion.ref == 'refs/heads/main'" + + oidc { + issuer_uri = "https://token.actions.githubusercontent.com" + } +} + +# Allow GitHub Actions identities from this repository to impersonate the deployer service account +resource "google_service_account_iam_member" "github_actions_workload_identity" { + service_account_id = google_service_account.github_deployer.name + + role = "roles/iam.workloadIdentityUser" + + member = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github_actions.workload_identity_pool_id}/attribute.repository/${var.github_org}/${var.github_repo}" +} + +# Current GCP project metadata used for IAM bindings and resource references +data "google_project" "current" { + project_id = var.project_id +} diff --git a/infra/outputs.tf b/infra/outputs.tf index 88a8456..93d14d5 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -5,3 +5,14 @@ output "redis_host" { output "redis_port" { value = google_redis_instance.cache.port } + + +# GitHub deployer service account email used by GitHub Actions authentication +output "github_deployer_service_account_email" { + value = google_service_account.github_deployer.email +} + +# Workload Identity Provider resource name used by GitHub Actions authentication +output "github_workload_identity_provider_name" { + value = google_iam_workload_identity_pool_provider.github_provider.name +} diff --git a/infra/staging.tfvars b/infra/staging.tfvars index cd265d9..c924915 100644 --- a/infra/staging.tfvars +++ b/infra/staging.tfvars @@ -1,2 +1,4 @@ project_id = "student-progress-staging" environment = "staging" +github_org = "aga87" +github_repo = "student-progress-api" diff --git a/infra/variables.tf b/infra/variables.tf index f0d5ff7..357cdbe 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -24,3 +24,11 @@ variable "db_name" { description = "Application database name" default = "student_progress" } + +variable "github_org" { + type = string +} + +variable "github_repo" { + type = string +}