diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d398393 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: build + +on: + push: + branches: [main] + paths-ignore: + - "infrastructure/**" + - "docs/**" + - "**.md" + pull_request: + paths-ignore: + - "infrastructure/**" + - "docs/**" + - "**.md" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Format check + run: | + diff <(gofmt -l .) <(echo -n "") + + - name: Vet + run: go vet ./... + + - name: Build (all cmd/* Lambdas) + run: ./scripts/build.sh + + - name: Test + run: go test ./... diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..ec71ef6 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,90 @@ +name: terraform + +on: + push: + branches: [main] + paths: + - "infrastructure/**" + - "configs/runner.yaml" + pull_request: + paths: + - "infrastructure/**" + - "configs/runner.yaml" + +jobs: + fmt-and-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "~> 1.16" + terraform_wrapper: false + + - uses: autero1/action-terragrunt@v3 + with: + terragrunt-version: "1.1.4" + + - name: Format check (Terragrunt HCL) + run: terragrunt hcl fmt --working-dir infrastructure --check --diff + + - name: Format check (the single Terraform module) + run: terraform fmt -recursive -check infrastructure/modules + + - name: Validate Terragrunt HCL + run: terragrunt hcl validate --working-dir infrastructure + + # `terraform validate` on the module directly, not `terragrunt + # validate` — the latter runs `terraform init` against the real S3 + # state backend, which doesn't exist yet for a repo that hasn't been + # deployed. This still fully validates the module's HCL/types without + # needing AWS credentials or a state bucket. + - name: Validate Terraform module + working-directory: infrastructure/modules/github-runner-on-aws + run: | + terraform init -backend=false -input=false + terraform validate + + # Plan requires this repo's own AWS account (for the S3 state backend and + # to talk to the AWS API), so it only runs once AWS_ROLE_TO_ASSUME is set + # as a repository secret (OIDC — see + # https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services). + plan: + needs: fmt-and-validate + if: ${{ secrets.AWS_ROLE_TO_ASSUME != '' }} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build Lambda binaries + run: | + ./scripts/build.sh + ./scripts/package.sh + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ vars.AWS_REGION || 'ap-southeast-1' }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "~> 1.16" + terraform_wrapper: false + + - uses: autero1/action-terragrunt@v3 + with: + terragrunt-version: "1.1.4" + + - name: Plan + working-directory: infrastructure/environments/main + run: | + terragrunt init -input=false + terragrunt plan -input=false diff --git a/.gitignore b/.gitignore index 33e57e9..e70b89b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,18 @@ .terraform +.terragrunt-cache lambda_function.zip *.tfstate.backup -*.tfstate \ No newline at end of file +*.tfstate + +# Generated by each environment's terragrunt.hcl `generate` blocks — never +# hand-edited, always overwritten on the next terragrunt run. +infrastructure/environments/*/backend.tf +infrastructure/environments/*/provider.tf +infrastructure/root.hcl + +# Go Lambda build artifacts (produced by scripts/build.sh + scripts/package.sh) +lambda/*/bootstrap +lambda/*.zip + +secrets.yaml +.terraform.lock.hcl diff --git a/.terraform-version b/.terraform-version new file mode 100644 index 0000000..f21c0ae --- /dev/null +++ b/.terraform-version @@ -0,0 +1 @@ +1.16.2 \ No newline at end of file diff --git a/.terragrunt-version b/.terragrunt-version new file mode 100644 index 0000000..1b87bcd --- /dev/null +++ b/.terragrunt-version @@ -0,0 +1 @@ +1.1.4 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e69856 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: build package deploy-plan deploy-apply fmt vet test tf-fmt tf-validate clean + +build: + ./scripts/build.sh + +package: build + ./scripts/package.sh + +deploy-plan: package + ./scripts/deploy.sh plan + +deploy-apply: package + ./scripts/deploy.sh apply + +fmt: + gofmt -l . + +vet: + go vet ./... + +test: + go test ./... + +tf-fmt: + terragrunt hcl fmt --working-dir infrastructure --check + terraform fmt -recursive -check infrastructure/modules + +# Credential-free: validates the Terragrunt HCL itself, then the Terraform +# module directly (no provider config or state backend needed — see +# infrastructure/modules/github-runner-on-aws). This does NOT need a real S3 +# state bucket; `terragrunt validate` (which does) only runs as part of +# `make deploy-plan`/`deploy-apply`. +tf-validate: + terragrunt hcl validate --working-dir infrastructure + cd infrastructure/modules/github-runner-on-aws && terraform init -backend=false -input=false >/dev/null && terraform validate + rm -rf infrastructure/modules/github-runner-on-aws/.terraform infrastructure/modules/github-runner-on-aws/.terraform.lock.hcl + +clean: + rm -rf lambda/*/bootstrap lambda/*.zip diff --git a/README.md b/README.md index f5085e7..b299c60 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,97 @@ # Provision self-hosted GitHub runners on-demand in AWS + ![shield](https://img.shields.io/badge/Scope-github_runners-blue) ![shield](https://img.shields.io/badge/Cloud_provider-AWS-orange) -![shield](https://img.shields.io/badge/Terrafrom->=v1.0-orange) +![shield](https://img.shields.io/badge/Terraform->=1.10-orange) +![shield](https://img.shields.io/badge/Terragrunt-1.x-blueviolet) +![shield](https://img.shields.io/badge/Language-Go-00ADD8) ![shield](https://img.shields.io/badge/Type-spot_instance-purple) -![shield](https://img.shields.io/badge/Permission-full_control-purple) ## Purpose -This project aims to provision self-hosted GitHub runners on-demand in AWS. It automates the setup and scaling of GitHub runners to handle CI/CD workloads efficiently. -## Features -- Scaling & Sustainability: Automatically calculating runners, scale up and down based on requesting. -- Life cycles: Self-killed when no jobs are running after particularly time. -- Security: Runners are created on-demand and terminated after use (ephemeral runners). -- Cost optimization: Runners are created on AWS spot instances. -- Runner level: Support organization and repository level runners. Enterprise level runners are not supported (yet). -- Multi-Runner: Create multiple runner configurations with a single deployment +This project provisions self-hosted GitHub Actions runners on-demand in AWS: event-driven, idempotent, and enterprise-grade. It replaces an earlier Python/TypeScript prototype (kept for reference, untouched, in [src_backup/](./src_backup)) with a Go implementation built around three Lambda functions, a single DynamoDB table for state, and a GitHub App for authentication — **no personal access token, ever**. ## Architecture -The architecture of this project is illustrated below: -![img](./docs/github-runner.drawio.png) +See [docs/infrastructure/enterprise-standard-upgrade.md](./docs/infrastructure/enterprise-standard-upgrade.md) for the full design and [docs/infrastructure/request-github-runner-token-architecture.md](./docs/infrastructure/request-github-runner-token-architecture.md) for the GitHub App authentication flow. In short: -## Scope -The scope of this project includes: -- Provisioning AWS resources required for GitHub runners. -- Configuring GitHub runners to scale based on demand. -- Ensuring secure and efficient communication between GitHub and AWS. +```text +GitHub (workflow_job webhook) + │ + ▼ +API Gateway -> webhook Lambda (verify signature, dedupe, normalize) + │ + ▼ + SQS (+ DLQ) + │ + ▼ +provision Lambda (profile + group resolution, allocate-or-provision) + │ │ + ▼ ▼ + DynamoDB GitHub App (registration token) -> EC2 Spot (runner) + ▲ + │ +EventBridge (rate(3m) + spot interruption) -> cleanup Lambda +``` -## Requirements -- AWS Account with appropriate permissions. -- GitHub account with repository access or organization access. -- Terraform v1.0 or later. -- AWS CLI configured with appropriate credentials. -- Docker installed on the local machine. +Developers only declare capability in their workflow — the label(s) just +need to match one of a profile's `labels` in `configs/runner.yaml`: -## Usage -Once the setup is complete, the GitHub runners will automatically scale based on the CI/CD workload. You can monitor and manage the runners through the AWS Management Console and GitHub repository settings. Follow these documents to install: +```yaml +runs-on: [self-hosted, erp-sport-amd64] +``` -1. [Install AutoRunner - Worker](./src/lambda-runner-worker/README.md) -2. [Install AutoRunner - API](./src/lambda-runner-api/README.md) +The platform — not the developer — decides AMI, instance type, Spot strategy, subnet, security group, IAM role, runner group, and registration token. -## Log -You can view logs through AWS CloudWatch - Log Groups: -``` -/aws/lambda/autorunner -/aws/lambda/autorunner-worker +## Repository layout + +```text +cmd/ Lambda entrypoints: webhook, provision, cleanup +internal/ Domain logic: config, github, runner, aws, store, webhook, cleanup +configs/runner.yaml Single source of truth: app policy AND infra settings +infrastructure/modules/github-runner-on-aws/ The one Terraform module — every AWS resource this project needs +infrastructure/root.hcl Shared Terragrunt config: S3 state backend (native locking, no DynamoDB) +infrastructure/environments/main/terragrunt.hcl The single environment: reads runner.yaml, calls the module +scripts/ build.sh, package.sh, deploy.sh +src_backup/ Prior implementation — reference only, not part of this build ``` +## Setup + +**Full walkthrough: [docs/SETUP.md](./docs/SETUP.md)** — create the GitHub +App, configure `configs/runner.yaml`, deploy with Terragrunt, populate +secrets, and verify end-to-end. Condensed version: + +1. Create a GitHub App (App ID, private key, webhook secret) — see [request-github-runner-token-architecture.md](./docs/infrastructure/request-github-runner-token-architecture.md). Install it on the target organization/repository. +2. Edit [configs/runner.yaml](./configs/runner.yaml) — the single source of truth for both the app policy (`github`/`webhook`/`runner`: App ID, secret names, scope/group, profiles and their labels) and the deployment knobs (`infrastructure.main`: region, existing-VPC IDs, Lambda timeouts, tags) that `infrastructure/environments/main/terragrunt.hcl` reads and passes to the Terraform module. Every field is documented inline (Helm `values.yaml`-style `-- comment` convention). +3. Copy [configs/secrets-example.yaml](./configs/secrets-example.yaml) to `configs/secrets.yaml` and fill in the GitHub App's private key and webhook secret from step 1. This file is gitignored and never committed; Terraform reads it and applies both secrets' values directly. +4. Fill in your state bucket/region in `infrastructure/root.hcl` (this is the one thing that can't come from `configs/runner.yaml` — Terraform's backend block can't read a file that might itself be the file telling it where to find its state). +5. Build and deploy: + + ```sh + make deploy-plan # builds + packages Lambdas, then terragrunt plan + make deploy-apply # ... then terragrunt apply + ``` + + This also writes both secrets' values to Secrets Manager — no separate step needed. +6. Point the GitHub App's webhook URL at the `webhook_url` output (`cd infrastructure/environments/main && terragrunt output -raw webhook_url`). + +## Requirements + +- Go 1.26+ (see go.mod) +- Terraform >= 1.10 (native S3 state locking), AWS provider ~> 5.0 +- Terragrunt >= 1.1 +- AWS account with appropriate permissions +- A GitHub App installed on the target organization or repository + +## Log + +CloudWatch Log Groups: `/aws/lambda/-main-webhook`, `-provision`, `-cleanup`. + ## Contributing -Contributions are welcome! Please create pull request to this repository. + +Contributions are welcome! Please create a pull request to this repository. ## License -This project is licensed under the MIT License. See the LICENSE file for more details. \ No newline at end of file + +This project is licensed under the MIT License. See the LICENSE file for more details. diff --git a/cmd/cleanup/main.go b/cmd/cleanup/main.go new file mode 100644 index 0000000..af76ba1 --- /dev/null +++ b/cmd/cleanup/main.go @@ -0,0 +1,116 @@ +// Command cleanup is the EventBridge-triggered Lambda: the rate(3 minutes) +// reconciliation sweep, and (via the same function, distinguished by +// detail-type) immediate handling of EC2 Spot interruption notices. See +// docs/infrastructure/enterprise-standard-upgrade.md sections 20, 30, 32. +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "strconv" + "time" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + + awsinternal "github.me/v2d27/github-runner-provisioner/internal/aws" + "github.me/v2d27/github-runner-provisioner/internal/cleanup" + "github.me/v2d27/github-runner-provisioner/internal/config" + ghclient "github.me/v2d27/github-runner-provisioner/internal/github" + "github.me/v2d27/github-runner-provisioner/internal/store" +) + +// spotInterruptionDetailType is the EventBridge detail-type for the +// "EC2 Spot Instance Interruption Warning" event, distinguishing it from +// the ordinary rate(3 minutes) "Scheduled Event". +const spotInterruptionDetailType = "EC2 Spot Instance Interruption Warning" + +type spotInterruptionDetail struct { + InstanceID string `json:"instance-id"` +} + +var ( + logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + service *cleanup.Service +) + +func init() { + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + logger.Error("cleanup: load config", "error", err) + os.Exit(1) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + logger.Error("cleanup: load aws config", "error", err) + os.Exit(1) + } + + secrets := awsinternal.NewSecrets(awsCfg) + privateKey, err := secrets.GetSecretString(ctx, cfg.GitHub.PrivateKeySecretName) + if err != nil { + logger.Error("cleanup: load github app private key", "error", err) + os.Exit(1) + } + + st := store.New(awsCfg, mustEnv("DYNAMODB_TABLE_NAME"), mustEnvDays("DYNAMODB_TTL_DAYS")) + ghProvider := ghclient.NewProvider(ghclient.AppCredentials{ + AppID: cfg.GitHub.AppID, + PrivateKey: []byte(privateKey), + }, cfg, st) + ec2Client := awsinternal.NewEC2(awsCfg) + + service = cleanup.NewService(cfg, st, ghProvider, ec2Client, logger) +} + +func handleEvent(ctx context.Context, event events.CloudWatchEvent) error { + if event.DetailType == spotInterruptionDetailType { + var detail spotInterruptionDetail + if err := json.Unmarshal(event.Detail, &detail); err != nil { + logger.Error("cleanup: unmarshal spot interruption detail", "error", err) + return err + } + if err := service.HandleSpotInterruption(ctx, detail.InstanceID); err != nil { + logger.Error("cleanup: handle spot interruption", "instance_id", detail.InstanceID, "error", err) + return err + } + return nil + } + + if err := service.RunScheduledSweep(ctx); err != nil { + logger.Error("cleanup: scheduled sweep", "error", err) + return err + } + return nil +} + +func mustEnv(name string) string { + v := os.Getenv(name) + if v == "" { + logger.Error("cleanup: missing required environment variable", "name", name) + os.Exit(1) + } + return v +} + +// mustEnvDays parses an environment variable holding a whole number of days +// (e.g. DYNAMODB_TTL_DAYS, from infrastructure.main.dynamodb_ttl_days) into +// a time.Duration. +func mustEnvDays(name string) time.Duration { + days, err := strconv.Atoi(mustEnv(name)) + if err != nil { + logger.Error("cleanup: parse environment variable as days", "name", name, "error", err) + os.Exit(1) + } + return time.Duration(days) * 24 * time.Hour +} + +func main() { + lambda.Start(handleEvent) +} diff --git a/cmd/provision/main.go b/cmd/provision/main.go new file mode 100644 index 0000000..95e7bcc --- /dev/null +++ b/cmd/provision/main.go @@ -0,0 +1,113 @@ +// Command provision is the SQS-triggered controller/reconciliation Lambda: +// profile resolution, runner allocation, and EC2 provisioning. See +// docs/infrastructure/enterprise-standard-upgrade.md section 19. +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "strconv" + "time" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + + awsinternal "github.me/v2d27/github-runner-provisioner/internal/aws" + "github.me/v2d27/github-runner-provisioner/internal/config" + ghclient "github.me/v2d27/github-runner-provisioner/internal/github" + "github.me/v2d27/github-runner-provisioner/internal/runner" + "github.me/v2d27/github-runner-provisioner/internal/store" + "github.me/v2d27/github-runner-provisioner/internal/webhook" +) + +var ( + logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + service *runner.Service +) + +func init() { + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + logger.Error("provision: load config", "error", err) + os.Exit(1) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + logger.Error("provision: load aws config", "error", err) + os.Exit(1) + } + + secrets := awsinternal.NewSecrets(awsCfg) + privateKey, err := secrets.GetSecretString(ctx, cfg.GitHub.PrivateKeySecretName) + if err != nil { + logger.Error("provision: load github app private key", "error", err) + os.Exit(1) + } + + st := store.New(awsCfg, mustEnv("DYNAMODB_TABLE_NAME"), mustEnvDays("DYNAMODB_TTL_DAYS")) + ghProvider := ghclient.NewProvider(ghclient.AppCredentials{ + AppID: cfg.GitHub.AppID, + PrivateKey: []byte(privateKey), + }, cfg, st) + ec2Client := awsinternal.NewEC2(awsCfg) + amiResolver := awsinternal.NewAMIResolver(awsCfg) + + service = runner.NewService(cfg, st, ghProvider, ec2Client, amiResolver, mustEnv("LAUNCH_TEMPLATE_ID"), mustEnv("READY_CALLBACK_URL"), logger) +} + +func handleSQSEvent(ctx context.Context, sqsEvent events.SQSEvent) (events.SQSEventResponse, error) { + var failures []events.SQSBatchItemFailure + + for _, record := range sqsEvent.Records { + var normalized webhook.NormalizedEvent + if err := json.Unmarshal([]byte(record.Body), &normalized); err != nil { + logger.Error("provision: unmarshal message", "message_id", record.MessageId, "error", err) + failures = append(failures, events.SQSBatchItemFailure{ItemIdentifier: record.MessageId}) + continue + } + + err := service.Handle(ctx, runner.Event{ + Action: normalized.Action, + JobID: normalized.JobID, + WorkflowRunID: normalized.WorkflowRunID, + Labels: normalized.Labels, + }) + if err != nil { + logger.Error("provision: handle event", "job_id", normalized.JobID, "action", normalized.Action, "error", err) + failures = append(failures, events.SQSBatchItemFailure{ItemIdentifier: record.MessageId}) + } + } + + return events.SQSEventResponse{BatchItemFailures: failures}, nil +} + +func mustEnv(name string) string { + v := os.Getenv(name) + if v == "" { + logger.Error("provision: missing required environment variable", "name", name) + os.Exit(1) + } + return v +} + +// mustEnvDays parses an environment variable holding a whole number of days +// (e.g. DYNAMODB_TTL_DAYS, from infrastructure.main.dynamodb_ttl_days) into +// a time.Duration. +func mustEnvDays(name string) time.Duration { + days, err := strconv.Atoi(mustEnv(name)) + if err != nil { + logger.Error("provision: parse environment variable as days", "name", name, "error", err) + os.Exit(1) + } + return time.Duration(days) * 24 * time.Hour +} + +func main() { + lambda.Start(handleSQSEvent) +} diff --git a/cmd/ready/main.go b/cmd/ready/main.go new file mode 100644 index 0000000..d1780c6 --- /dev/null +++ b/cmd/ready/main.go @@ -0,0 +1,74 @@ +// Command ready is the API Gateway HTTP API Lambda that runner instances +// call back once one of their runner processes registers with GitHub and +// starts — see internal/ready and store.MarkSlotReady. +package main + +import ( + "context" + "encoding/base64" + "log/slog" + "os" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + + "github.me/v2d27/github-runner-provisioner/internal/ready" + "github.me/v2d27/github-runner-provisioner/internal/store" +) + +var ( + logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + handler *ready.Handler +) + +func init() { + ctx := context.Background() + + // No RUNNER_CONFIG_JSON here: this Lambda only ever touches store.Client + // (keyed by DYNAMODB_TABLE_NAME below), never GitHub or runner.yaml's + // policy knobs. + awsCfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + logger.Error("ready: load aws config", "error", err) + os.Exit(1) + } + + // recordTTL is 0 (unused): MarkSlotReady, the only store method this + // Lambda ever calls, never writes a ttl attribute. + st := store.New(awsCfg, mustEnv("DYNAMODB_TABLE_NAME"), 0) + handler = ready.NewHandler(st, logger) +} + +func handleRequest(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + body := []byte(req.Body) + if req.IsBase64Encoded { + decoded, err := base64.StdEncoding.DecodeString(req.Body) + if err != nil { + logger.Error("ready: decode base64 body", "error", err) + return events.APIGatewayV2HTTPResponse{StatusCode: 400}, nil + } + body = decoded + } + + status, err := handler.Handle(ctx, body) + if err != nil { + logger.Error("ready: handle request", "status", status, "error", err) + } + // As with cmd/webhook: always a nil Go error so API Gateway relays the + // intended status to the caller instead of a generic 502. + return events.APIGatewayV2HTTPResponse{StatusCode: status}, nil +} + +func mustEnv(name string) string { + v := os.Getenv(name) + if v == "" { + logger.Error("ready: missing required environment variable", "name", name) + os.Exit(1) + } + return v +} + +func main() { + lambda.Start(handleRequest) +} diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go new file mode 100644 index 0000000..6608fad --- /dev/null +++ b/cmd/webhook/main.go @@ -0,0 +1,102 @@ +// Command webhook is the API Gateway HTTP API Lambda: it verifies incoming +// GitHub webhook deliveries, dedupes them, and forwards normalized +// workflow_job events to SQS for cmd/provision to act on. See +// docs/infrastructure/enterprise-standard-upgrade.md section 25. +package main + +import ( + "context" + "encoding/base64" + "log/slog" + "os" + "strconv" + "time" + + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + + awsinternal "github.me/v2d27/github-runner-provisioner/internal/aws" + "github.me/v2d27/github-runner-provisioner/internal/config" + "github.me/v2d27/github-runner-provisioner/internal/store" + "github.me/v2d27/github-runner-provisioner/internal/webhook" +) + +var ( + logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + handler *webhook.Handler +) + +func init() { + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + logger.Error("webhook: load config", "error", err) + os.Exit(1) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + logger.Error("webhook: load aws config", "error", err) + os.Exit(1) + } + + secrets := awsinternal.NewSecrets(awsCfg) + secretValue, err := secrets.GetSecretString(ctx, cfg.Webhook.SecretName) + if err != nil { + logger.Error("webhook: load webhook secret", "error", err) + os.Exit(1) + } + + st := store.New(awsCfg, mustEnv("DYNAMODB_TABLE_NAME"), mustEnvDays("DYNAMODB_TTL_DAYS")) + queue := webhook.NewQueue(awsCfg, mustEnv("QUEUE_URL")) + + handler = webhook.NewHandler([]byte(secretValue), st, queue, logger) +} + +func handleRequest(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + body := []byte(req.Body) + if req.IsBase64Encoded { + decoded, err := base64.StdEncoding.DecodeString(req.Body) + if err != nil { + logger.Error("webhook: decode base64 body", "error", err) + return events.APIGatewayV2HTTPResponse{StatusCode: 400}, nil + } + body = decoded + } + + status, err := handler.Handle(ctx, req.Headers, body) + if err != nil { + logger.Error("webhook: handle request", "status", status, "error", err) + } + // Always return the intended HTTP status via a nil Go error, so API + // Gateway relays it to GitHub as-is instead of a generic 502 — GitHub's + // own webhook retry behavior is driven by that status code. + return events.APIGatewayV2HTTPResponse{StatusCode: status}, nil +} + +func mustEnv(name string) string { + v := os.Getenv(name) + if v == "" { + logger.Error("webhook: missing required environment variable", "name", name) + os.Exit(1) + } + return v +} + +// mustEnvDays parses an environment variable holding a whole number of days +// (e.g. DYNAMODB_TTL_DAYS, from infrastructure.main.dynamodb_ttl_days) into +// a time.Duration. +func mustEnvDays(name string) time.Duration { + days, err := strconv.Atoi(mustEnv(name)) + if err != nil { + logger.Error("webhook: parse environment variable as days", "name", name, "error", err) + os.Exit(1) + } + return time.Duration(days) * 24 * time.Hour +} + +func main() { + lambda.Start(handleRequest) +} diff --git a/configs/runner.yaml b/configs/runner.yaml new file mode 100644 index 0000000..9fe410f --- /dev/null +++ b/configs/runner.yaml @@ -0,0 +1,162 @@ +# Single source of truth for this platform — both the application policy +# (github/webhook/runner, below) and the Terragrunt/Terraform deployment +# knobs (infrastructure, at the bottom). +# +# terraform/environments/main/terragrunt.hcl reads this whole file: +# - github/webhook/runner are re-encoded as JSON and handed to every Lambda +# as the RUNNER_CONFIG_JSON environment variable. +# - infrastructure.main supplies the Terraform module's inputs — one edit +# here updates both the app config and the infra it deploys onto, so the +# two can never drift apart. +# +# Comments follow a "-- " convention (as in a Helm chart's +# values.yaml) so every field is self-documenting from this file alone. +# +# Developers never see or choose any of this: they only declare +# `runs-on: [self-hosted,