mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-08 07:44:57 +00:00
15 KiB
15 KiB
GitHub PR Reconciler Plan
Overview
Deploy an example GitHub PR reconciler that logs PR events from imjasonh/terraform-playground. The system will:
- Receive GitHub webhook events via a CloudEvents broker
- Route PR events to a workqueue
- 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
-
Go to https://github.com/settings/apps/new (or org settings for org-owned app)
-
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
- GitHub App name:
-
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)
- Repository permissions:
-
Subscribe to events:
- Check Pull request
-
Where can this GitHub App be installed?
- Select Only on this account for testing
-
Click Create GitHub App
Step 2: Generate and Save Private Key
- After creating the app, scroll down to Private keys
- Click Generate a private key
- Save the downloaded
.pemfile securely
Step 3: Install the App
- Go to the app's page: https://github.com/settings/apps/driftlessaf-pr-logger
- Click Install App in the left sidebar
- Select your account
- Choose Only select repositories and pick
terraform-playground - 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
- Create the GitHub App (follow instructions above)
- Store secrets in Secret Manager
- Create
terraform.tfvars:project_id = "jason-chainguard" github_app_id = "YOUR_APP_ID" github_installation_id = "YOUR_INSTALLATION_ID" - Run Terraform:
cd driftlessaf terraform init terraform plan terraform apply - Update GitHub App webhook URL with the output value
- 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