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

495 lines
15 KiB
Markdown

# 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](https://github.com/octo-sts/app) 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`](https://github.com/bradleyfalzon/ghinstallation):
```go
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`)
```hcl
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`)
```hcl
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`)
```hcl
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`)
```hcl
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`)
```hcl
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`)
```hcl
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`
```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
```bash
# 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`:
```hcl
project_id = "jason-chainguard"
github_app_id = "YOUR_APP_ID"
github_installation_id = "YOUR_INSTALLATION_ID"
```
4. Run Terraform:
```bash
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