Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 97 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ utilities for configuration management in containerized environments.

## Features

- **Unified runtime** - Single API for both HTTP servers and Lambda functions
- **Web-based installer** - User-friendly UI for creating GitHub Apps with
pre-configured permissions
- **Multiple storage backends** - AWS SSM Parameter Store, `.env` files, or
individual files
- **Hot reload support** - Reload configuration via SIGHUP or programmatic
triggers
- **Hot reload support** - Reload configuration via SIGHUP or installer callback
- **SSM ARN resolution** - Resolve AWS SSM Parameter Store ARNs in environment
variables (useful for Lambda)
- **Ready gate** - HTTP middleware that returns 503 until configuration is
Expand All @@ -28,67 +28,94 @@ go get github.com/cruxstack/github-app-setup-go

| Package | Description |
|---------------|-----------------------------------------------------------|
| `ghappsetup` | **Unified runtime** for HTTP servers and Lambda functions |
| `installer` | HTTP handler implementing the GitHub App Manifest flow |
| `configstore` | Storage backends for GitHub App credentials |
| `configwait` | Startup wait logic, ready gate middleware, and reload |
| `configwait` | Startup wait logic and ready gate middleware |
| `ssmresolver` | Resolves SSM Parameter Store ARNs in environment vars |

## Quick Start

The `ghappsetup.Runtime` provides unified lifecycle management for both HTTP
servers and Lambda functions:

```go
package main

import (
"context"
"fmt"
"log"
"net/http"
"os"

"github.com/cruxstack/github-app-setup-go/configstore"
"github.com/cruxstack/github-app-setup-go/configwait"
"github.com/cruxstack/github-app-setup-go/ghappsetup"
"github.com/cruxstack/github-app-setup-go/installer"
)

func main() {
ctx := context.Background()

// Create a storage backend (uses STORAGE_MODE env var, defaults to .env file)
store, err := configstore.NewFromEnv()
// Create runtime with unified lifecycle management
runtime, err := ghappsetup.NewRuntime(ghappsetup.Config{
LoadFunc: loadConfig,
AllowedPaths: []string{"/healthz", "/setup", "/callback", "/"},
})
if err != nil {
log.Fatal(err)
}

// Define the GitHub App manifest with required permissions
manifest := installer.Manifest{
URL: "https://example.com",
Public: false,
DefaultPerms: map[string]string{
"contents": "read",
"pull_requests": "write",
// Set up routes
mux := http.NewServeMux()
mux.HandleFunc("/healthz", runtime.HealthHandler())
mux.HandleFunc("/webhook", webhookHandler)

// Create installer using convenience method (auto-wires Store and reload callback)
installerHandler, err := runtime.InstallerHandler(installer.Config{
Manifest: installer.Manifest{
URL: "https://example.com",
Public: false,
DefaultPerms: map[string]string{
"contents": "read",
"pull_requests": "write",
},
DefaultEvents: []string{"pull_request", "push"},
},
DefaultEvents: []string{"pull_request", "push"},
}

// Create the installer handler
installerHandler, err := installer.New(installer.Config{
Store: store,
Manifest: manifest,
AppDisplayName: "My GitHub App",
})
if err != nil {
log.Fatal(err)
}

// Set up routes
mux := http.NewServeMux()
mux.Handle("/setup", installerHandler)
mux.Handle("/callback", installerHandler)

// Create a ready gate that allows /setup through before app is configured
gate := configwait.NewReadyGate(mux, []string{"/setup", "/callback", "/healthz"})
// Start HTTP server with ReadyGate middleware
srv := &http.Server{
Addr: ":8080",
Handler: runtime.Handler(mux),
}
go srv.ListenAndServe()

// Block until config loads, then listen for SIGHUP reloads
if err := runtime.Start(ctx); err != nil {
log.Fatal(err)
}
log.Println("Configuration loaded, service is ready")
runtime.ListenForReloads(ctx)
}

func loadConfig(ctx context.Context) error {
// Validate required environment variables are present
if os.Getenv("GITHUB_APP_ID") == "" {
return fmt.Errorf("GITHUB_APP_ID not set")
}
return nil
}

// Start the server
log.Println("Starting server on :8080")
log.Fatal(http.ListenAndServe(":8080", gate))
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// Handle GitHub webhooks
w.WriteHeader(http.StatusOK)
}
```

Expand Down Expand Up @@ -158,26 +185,54 @@ store := configstore.NewLocalFileStore("./secrets/")

## Hot Reload

The library supports hot-reloading configuration via SIGHUP signals or
programmatic triggers:
The Runtime supports hot-reloading configuration via SIGHUP signals. When the
installer saves new credentials, it automatically triggers a reload via the
callback:

```go
// Create a reloader that calls your reload function
reloader := configwait.NewReloader(ctx, gate, func(ctx context.Context) error {
// Reload your configuration here
newHandler := buildHandler()
gate.SetHandler(newHandler)
gate.SetReady()
return nil
})
// ListenForReloads handles both SIGHUP signals and installer callbacks
runtime.ListenForReloads(ctx)
```

For manual reload triggering:

```go
// Trigger a reload programmatically
runtime.Reload()
```

## Lambda Usage

// Set as global reloader (allows installer to trigger reload after saving)
configwait.SetGlobalReloader(reloader)
For AWS Lambda functions, use `EnsureLoaded()` for lazy initialization:

// Start listening for SIGHUP
reloader.Start()
```go
var runtime *ghappsetup.Runtime

func init() {
runtime, _ = ghappsetup.NewRuntime(ghappsetup.Config{
LoadFunc: func(ctx context.Context) error {
// Resolve SSM parameters passed as ARNs
if err := ssmresolver.ResolveEnvironmentWithDefaults(ctx); err != nil {
return err
}
return validateConfig()
},
})
}

func handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (Response, error) {
// Lazy-load config with retries (idempotent after first success)
if err := runtime.EnsureLoaded(ctx); err != nil {
return Response{StatusCode: 503, Body: "Service unavailable"}, nil
}
return handleRequest(ctx, req)
}
```

The Runtime auto-detects Lambda environments and adjusts retry settings:
- **HTTP**: 30 retries, 2-second intervals (suitable for startup)
- **Lambda**: 5 retries, 1-second intervals (suitable for cold starts)

## SSM ARN Resolution

For Lambda deployments where secrets are passed as SSM ARNs:
Expand Down
38 changes: 0 additions & 38 deletions configwait/configwait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,41 +358,3 @@ func TestReloader_ContextCancellation(t *testing.T) {
t.Error("Reloader did not stop after context cancellation")
}
}

func TestGlobalReloader(t *testing.T) {
// Clear any existing global reloader
SetGlobalReloader(nil)

// TriggerReload should be a no-op when no global reloader is set
TriggerReload() // Should not panic

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

gate := NewReadyGate(nil, nil)

var reloadCount atomic.Int32
reloadFunc := func(ctx context.Context) error {
reloadCount.Add(1)
return nil
}

reloader := NewReloader(ctx, gate, reloadFunc)
reloader.Start()

// Set global reloader
SetGlobalReloader(reloader)

// Now TriggerReload should work
TriggerReload()

// Wait for reload to complete
time.Sleep(50 * time.Millisecond)

if got := reloadCount.Load(); got != 1 {
t.Errorf("Reload count = %d, want 1", got)
}

// Clean up
SetGlobalReloader(nil)
}
44 changes: 0 additions & 44 deletions configwait/reloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,47 +106,3 @@ func (r *Reloader) doReload() {

log.Infof("[reloader] configuration reloaded successfully")
}

var (
globalReloaderMu sync.RWMutex
globalReloader *Reloader

reloadCounter int64
reloadCounterMu sync.Mutex
)

// SetGlobalReloader sets the global reloader instance.
func SetGlobalReloader(r *Reloader) {
globalReloaderMu.Lock()
defer globalReloaderMu.Unlock()
globalReloader = r
}

// TriggerReload triggers a reload using the global reloader (no-op if unset).
func TriggerReload() {
reloadCounterMu.Lock()
reloadCounter++
reloadCounterMu.Unlock()

globalReloaderMu.RLock()
r := globalReloader
globalReloaderMu.RUnlock()

if r != nil {
r.Trigger()
}
}

// GetReloadCount returns the number of times TriggerReload has been called.
func GetReloadCount() int64 {
reloadCounterMu.Lock()
defer reloadCounterMu.Unlock()
return reloadCounter
}

// ResetReloadCounter resets the reload counter to zero.
func ResetReloadCounter() {
reloadCounterMu.Lock()
defer reloadCounterMu.Unlock()
reloadCounter = 0
}
Loading