1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-08 07:44:57 +00:00
terraform-playground/driftlessaf/plan.md
Jason Hall 9f1f8c558f add driftless example
Signed-off-by: Jason Hall <jason@chainguard.dev>
2026-02-03 10:02:36 -05:00

15 KiB

GitHub PR Reconciler Plan

Overview

Deploy an example GitHub PR reconciler that logs PR events from imjasonh/terraform-playground. The system will:

  1. Receive GitHub webhook events via a CloudEvents broker
  2. Route PR events to a workqueue
  3. Process events in a regional Go reconciler service that logs parsed PR details

Architecture

GitHub (terraform-playground repo)
    │
    │ Webhooks (PR events)
    ▼
┌─────────────────────────────────────┐
│  github-events (trampoline)         │
│  - Validates webhook signature      │
│  - Converts to CloudEvents          │
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  cloudevent-broker                  │
│  - Regional Pub/Sub topics          │
│  - us-central1, us-east4            │
└─────────────────────────────────────┘
    │
    │ cloudevent-trigger (filters PR events)
    ▼
┌─────────────────────────────────────┐
│  cloudevents-workqueue              │
│  - Extracts PR URL as key           │
│  - Deduplicates concurrent events   │
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  regional-go-reconciler             │
│  - Workqueue dispatcher             │
│  - PR reconciler service            │
│  - Logs PR details via GitHub API   │
└─────────────────────────────────────┘

GitHub App Authentication

Why Not Octo STS?

The go-driftlessaf library uses Octo STS which is a token exchange service that:

  • Requires deploying and managing the Octo STS infrastructure
  • Exchanges workload identity tokens for GitHub installation tokens
  • Adds an extra hop in the authentication flow

Direct GitHub App Approach

For this example, we'll use direct GitHub App authentication via ghinstallation:

import (
    "github.com/bradleyfalzon/ghinstallation/v2"
    "github.com/google/go-github/v68/github"
)

// Create transport from App ID, Installation ID, and private key
itr, err := ghinstallation.New(http.DefaultTransport, appID, installationID, privateKey)
client := github.NewClient(&http.Client{Transport: itr})

This requires:

  • GitHub App ID (from app settings)
  • Installation ID (from installing the app on the repo)
  • Private key (stored in Google Secret Manager)

The reconciler service will read these from environment variables / secrets at runtime.

Components to Deploy

1. Networking (networking.tf)

module "networking" {
  source = "chainguard-dev/common/infra//modules/networking"

  project_id = var.project_id
  name       = "driftlessaf"
  regions    = ["us-central1", "us-east4"]
}

2. CloudEvents Broker (broker.tf)

module "cloudevent-broker" {
  source = "chainguard-dev/common/infra//modules/cloudevent-broker"

  project_id = var.project_id
  name       = "driftlessaf"
  regions    = module.networking.regional-networks

  notification_channels = []
}

3. GitHub Events Receiver (github-events.tf)

module "github-events" {
  source = "chainguard-dev/common/infra//modules/github-events"

  project_id = var.project_id
  name       = "driftlessaf"
  regions    = module.networking.regional-networks
  broker     = module.cloudevent-broker.broker

  # Filter to only accept events from your org/repo
  github_organizations = "imjasonh"

  notification_channels = []
}

4. PR Reconciler (reconciler.tf)

module "pr-reconciler" {
  source = "driftlessaf/reconcilers/infra//modules/regional-go-reconciler"

  project_id = var.project_id
  name       = "pr-logger"
  regions    = module.networking.regional-networks

  service_account = google_service_account.reconciler.email

  containers = {
    "reconciler" = {
      source = {
        working_dir = "${path.module}/cmd/pr-reconciler"
        importpath  = "."
      }
      ports = [{ container_port = 8080 }]
      env = [
        { name = "GITHUB_APP_ID", value = var.github_app_id },
        { name = "GITHUB_INSTALLATION_ID", value = var.github_installation_id },
      ]
      volume_mounts = [{
        name       = "github-app-key"
        mount_path = "/secrets/github"
        read_only  = true
      }]
    }
  }

  volumes = [{
    name = "github-app-key"
    secret = {
      secret_name = google_secret_manager_secret.github_app_key.secret_id
    }
  }]

  notification_channels = []
}

5. CloudEvents Trigger (trigger.tf)

module "pr-trigger" {
  source = "chainguard-dev/common/infra//modules/cloudevent-trigger"

  project_id = var.project_id
  name       = "pr-events"
  regions    = module.networking.regional-networks
  broker     = module.cloudevent-broker.broker

  # Filter for PR events only
  filter = {
    "type" = "dev.chainguard.github.pull_request"
  }

  private-service = {
    name   = module.pr-reconciler.name
    region = keys(module.networking.regional-networks)[0]
  }

  notification_channels = []
}

6. Secrets (secrets.tf)

resource "google_secret_manager_secret" "github_app_key" {
  project   = var.project_id
  secret_id = "github-app-private-key"

  replication {
    auto {}
  }
}

resource "google_secret_manager_secret" "webhook_secret" {
  project   = var.project_id
  secret_id = "github-webhook-secret"

  replication {
    auto {}
  }
}

Go Service Implementation

cmd/pr-reconciler/main.go

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "strconv"

    "github.com/bradleyfalzon/ghinstallation/v2"
    "github.com/chainguard-dev/clog"
    "github.com/driftlessaf/go-driftlessaf/reconcilers/githubreconciler"
    "github.com/driftlessaf/go-driftlessaf/workqueue"
    "github.com/google/go-github/v68/github"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

func main() {
    ctx := context.Background()
    log := clog.FromContext(ctx)

    // Read GitHub App credentials
    appID, _ := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
    installID, _ := strconv.ParseInt(os.Getenv("GITHUB_INSTALLATION_ID"), 10, 64)
    privateKey, _ := os.ReadFile("/secrets/github/private-key.pem")

    // Create GitHub client with App authentication
    itr, err := ghinstallation.New(http.DefaultTransport, appID, installID, privateKey)
    if err != nil {
        log.Error("failed to create GitHub transport", "error", err)
        os.Exit(1)
    }
    ghClient := github.NewClient(&http.Client{Transport: itr})

    // Create reconciler
    reconciler := &PRReconciler{
        gh:  ghClient,
        log: log,
    }

    // Start gRPC server for workqueue
    workqueue.ServeReconciler(ctx, reconciler)
}

type PRReconciler struct {
    workqueue.UnimplementedWorkqueueServiceServer
    gh  *github.Client
    log *slog.Logger
}

func (r *PRReconciler) Process(ctx context.Context, req *workqueue.ProcessRequest) (*workqueue.ProcessResponse, error) {
    log := r.log.With("key", req.Key)

    // Parse the PR URL
    resource, err := githubreconciler.ParseURL(req.Key)
    if err != nil {
        log.Error("failed to parse PR URL", "error", err)
        return nil, status.Error(codes.InvalidArgument, err.Error())
    }

    if resource.Type != githubreconciler.PullRequest {
        log.Warn("received non-PR event", "type", resource.Type)
        return &workqueue.ProcessResponse{}, nil
    }

    // Fetch PR details from GitHub API
    pr, _, err := r.gh.PullRequests.Get(ctx, resource.Owner, resource.Repo, resource.Number)
    if err != nil {
        log.Error("failed to fetch PR", "error", err)
        return nil, err
    }

    // Log basic PR details
    log.Info("processing PR",
        "owner", resource.Owner,
        "repo", resource.Repo,
        "number", resource.Number,
        "title", pr.GetTitle(),
        "state", pr.GetState(),
        "author", pr.GetUser().GetLogin(),
        "head", pr.GetHead().GetRef(),
        "base", pr.GetBase().GetRef(),
        "mergeable", pr.GetMergeable(),
        "additions", pr.GetAdditions(),
        "deletions", pr.GetDeletions(),
        "changed_files", pr.GetChangedFiles(),
    )

    // Log labels
    var labelNames []string
    for _, label := range pr.Labels {
        labelNames = append(labelNames, label.GetName())
    }
    log.Info("PR labels", "labels", labelNames)

    // Fetch and log reviews
    reviews, _, err := r.gh.PullRequests.ListReviews(ctx, resource.Owner, resource.Repo, resource.Number, nil)
    if err != nil {
        log.Warn("failed to fetch reviews", "error", err)
    } else {
        for _, review := range reviews {
            log.Info("PR review",
                "reviewer", review.GetUser().GetLogin(),
                "state", review.GetState(),
                "submitted_at", review.GetSubmittedAt(),
            )
        }
    }

    // Fetch and log CI status (combined status + check runs)
    combinedStatus, _, err := r.gh.Repositories.GetCombinedStatus(ctx, resource.Owner, resource.Repo, pr.GetHead().GetSHA(), nil)
    if err != nil {
        log.Warn("failed to fetch combined status", "error", err)
    } else {
        log.Info("PR combined status",
            "state", combinedStatus.GetState(),
            "total_count", combinedStatus.GetTotalCount(),
        )
        for _, status := range combinedStatus.Statuses {
            log.Info("PR status",
                "context", status.GetContext(),
                "state", status.GetState(),
                "description", status.GetDescription(),
            )
        }
    }

    checkRuns, _, err := r.gh.Checks.ListCheckRunsForRef(ctx, resource.Owner, resource.Repo, pr.GetHead().GetSHA(), nil)
    if err != nil {
        log.Warn("failed to fetch check runs", "error", err)
    } else {
        for _, check := range checkRuns.CheckRuns {
            log.Info("PR check run",
                "name", check.GetName(),
                "status", check.GetStatus(),
                "conclusion", check.GetConclusion(),
            )
        }
    }

    // Fetch and log changed files
    files, _, err := r.gh.PullRequests.ListFiles(ctx, resource.Owner, resource.Repo, resource.Number, nil)
    if err != nil {
        log.Warn("failed to fetch changed files", "error", err)
    } else {
        for _, file := range files {
            log.Info("PR file change",
                "filename", file.GetFilename(),
                "status", file.GetStatus(),
                "additions", file.GetAdditions(),
                "deletions", file.GetDeletions(),
            )
        }
    }

    return &workqueue.ProcessResponse{}, nil
}

GitHub App Setup Instructions

Step 1: Create the GitHub App

  1. Go to https://github.com/settings/apps/new (or org settings for org-owned app)

  2. Fill in the basic information:

    • GitHub App name: driftlessaf-pr-logger (must be unique)
    • Homepage URL: https://github.com/imjasonh/terraform-playground
    • Webhook URL: Will be filled in after Terraform apply (the github-events ingress URL)
    • Webhook secret: Generate a random string, save it for later
  3. Set permissions:

    • Repository permissions:
      • Pull requests: Read-only (to fetch PR details)
      • Contents: Read-only (if you want to read file contents)
      • Metadata: Read-only (required, auto-selected)
  4. Subscribe to events:

    • Check Pull request
  5. Where can this GitHub App be installed?

    • Select Only on this account for testing
  6. Click Create GitHub App

Step 2: Generate and Save Private Key

  1. After creating the app, scroll down to Private keys
  2. Click Generate a private key
  3. Save the downloaded .pem file securely

Step 3: Install the App

  1. Go to the app's page: https://github.com/settings/apps/driftlessaf-pr-logger
  2. Click Install App in the left sidebar
  3. Select your account
  4. Choose Only select repositories and pick terraform-playground
  5. Click Install

Step 4: Note the IDs

After installation, you'll need:

  • App ID: Found on the app's settings page (General tab, near the top)
  • Installation ID: Found in the URL after installing: https://github.com/settings/installations/INSTALLATION_ID

Step 5: Store Secrets in GCP

# Store the private key
gcloud secrets versions add github-app-private-key \
  --project=jason-chainguard \
  --data-file=path/to/your-app.private-key.pem

# Store the webhook secret
echo -n "your-webhook-secret" | gcloud secrets versions add github-webhook-secret \
  --project=jason-chainguard \
  --data-file=-

Step 6: Configure Webhook URL

After terraform apply, update the GitHub App's webhook URL to the github-events ingress URL output by Terraform.

File Structure

driftlessaf/
├── main.tf              # Provider config, variables
├── networking.tf        # VPC and regional networks
├── broker.tf            # CloudEvents broker
├── github-events.tf     # GitHub webhook receiver
├── secrets.tf           # Secret Manager resources
├── reconciler.tf        # PR reconciler service
├── trigger.tf           # CloudEvent trigger for PR events
├── variables.tf         # Input variables
├── outputs.tf           # Output values (webhook URL, etc.)
└── cmd/
    └── pr-reconciler/
        ├── main.go      # Reconciler implementation
        └── go.mod       # Go module

Variables Required

Variable Description Example
project_id GCP project jason-chainguard
github_app_id GitHub App ID 123456
github_installation_id Installation ID 78901234

Deployment Steps

  1. Create the GitHub App (follow instructions above)
  2. Store secrets in Secret Manager
  3. Create terraform.tfvars:
    project_id              = "jason-chainguard"
    github_app_id           = "YOUR_APP_ID"
    github_installation_id  = "YOUR_INSTALLATION_ID"
    
  4. Run Terraform:
    cd driftlessaf
    terraform init
    terraform plan
    terraform apply
    
  5. Update GitHub App webhook URL with the output value
  6. Test by opening/updating a PR in terraform-playground

Decisions Made

  • Notification channels: Not needed for now
  • Workqueue configuration: Using defaults (20 concurrent workers, 100 max retries)
  • Additional PR data: Logging labels, reviews, CI status, and file changes
  • Future actions: To be tackled later