Skip to content

Commit 02875ab

Browse files
committed
feat: add unified runtime for lifecycle management
1 parent 05635d5 commit 02875ab

14 files changed

Lines changed: 1563 additions & 181 deletions

File tree

README.md

Lines changed: 97 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ utilities for configuration management in containerized environments.
77

88
## Features
99

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

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

3637
## Quick Start
3738

39+
The `ghappsetup.Runtime` provides unified lifecycle management for both HTTP
40+
servers and Lambda functions:
41+
3842
```go
3943
package main
4044

4145
import (
4246
"context"
47+
"fmt"
4348
"log"
4449
"net/http"
50+
"os"
4551

46-
"github.com/cruxstack/github-app-setup-go/configstore"
47-
"github.com/cruxstack/github-app-setup-go/configwait"
52+
"github.com/cruxstack/github-app-setup-go/ghappsetup"
4853
"github.com/cruxstack/github-app-setup-go/installer"
4954
)
5055

5156
func main() {
5257
ctx := context.Background()
5358

54-
// Create a storage backend (uses STORAGE_MODE env var, defaults to .env file)
55-
store, err := configstore.NewFromEnv()
59+
// Create runtime with unified lifecycle management
60+
runtime, err := ghappsetup.NewRuntime(ghappsetup.Config{
61+
LoadFunc: loadConfig,
62+
AllowedPaths: []string{"/healthz", "/setup", "/callback", "/"},
63+
})
5664
if err != nil {
5765
log.Fatal(err)
5866
}
5967

60-
// Define the GitHub App manifest with required permissions
61-
manifest := installer.Manifest{
62-
URL: "https://example.com",
63-
Public: false,
64-
DefaultPerms: map[string]string{
65-
"contents": "read",
66-
"pull_requests": "write",
68+
// Set up routes
69+
mux := http.NewServeMux()
70+
mux.HandleFunc("/healthz", runtime.HealthHandler())
71+
mux.HandleFunc("/webhook", webhookHandler)
72+
73+
// Create installer using convenience method (auto-wires Store and reload callback)
74+
installerHandler, err := runtime.InstallerHandler(installer.Config{
75+
Manifest: installer.Manifest{
76+
URL: "https://example.com",
77+
Public: false,
78+
DefaultPerms: map[string]string{
79+
"contents": "read",
80+
"pull_requests": "write",
81+
},
82+
DefaultEvents: []string{"pull_request", "push"},
6783
},
68-
DefaultEvents: []string{"pull_request", "push"},
69-
}
70-
71-
// Create the installer handler
72-
installerHandler, err := installer.New(installer.Config{
73-
Store: store,
74-
Manifest: manifest,
7584
AppDisplayName: "My GitHub App",
7685
})
7786
if err != nil {
7887
log.Fatal(err)
7988
}
8089

81-
// Set up routes
82-
mux := http.NewServeMux()
8390
mux.Handle("/setup", installerHandler)
8491
mux.Handle("/callback", installerHandler)
8592

86-
// Create a ready gate that allows /setup through before app is configured
87-
gate := configwait.NewReadyGate(mux, []string{"/setup", "/callback", "/healthz"})
93+
// Start HTTP server with ReadyGate middleware
94+
srv := &http.Server{
95+
Addr: ":8080",
96+
Handler: runtime.Handler(mux),
97+
}
98+
go srv.ListenAndServe()
99+
100+
// Block until config loads, then listen for SIGHUP reloads
101+
if err := runtime.Start(ctx); err != nil {
102+
log.Fatal(err)
103+
}
104+
log.Println("Configuration loaded, service is ready")
105+
runtime.ListenForReloads(ctx)
106+
}
107+
108+
func loadConfig(ctx context.Context) error {
109+
// Validate required environment variables are present
110+
if os.Getenv("GITHUB_APP_ID") == "" {
111+
return fmt.Errorf("GITHUB_APP_ID not set")
112+
}
113+
return nil
114+
}
88115

89-
// Start the server
90-
log.Println("Starting server on :8080")
91-
log.Fatal(http.ListenAndServe(":8080", gate))
116+
func webhookHandler(w http.ResponseWriter, r *http.Request) {
117+
// Handle GitHub webhooks
118+
w.WriteHeader(http.StatusOK)
92119
}
93120
```
94121

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

159186
## Hot Reload
160187

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

164192
```go
165-
// Create a reloader that calls your reload function
166-
reloader := configwait.NewReloader(ctx, gate, func(ctx context.Context) error {
167-
// Reload your configuration here
168-
newHandler := buildHandler()
169-
gate.SetHandler(newHandler)
170-
gate.SetReady()
171-
return nil
172-
})
193+
// ListenForReloads handles both SIGHUP signals and installer callbacks
194+
runtime.ListenForReloads(ctx)
195+
```
196+
197+
For manual reload triggering:
198+
199+
```go
200+
// Trigger a reload programmatically
201+
runtime.Reload()
202+
```
203+
204+
## Lambda Usage
173205

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

177-
// Start listening for SIGHUP
178-
reloader.Start()
208+
```go
209+
var runtime *ghappsetup.Runtime
210+
211+
func init() {
212+
runtime, _ = ghappsetup.NewRuntime(ghappsetup.Config{
213+
LoadFunc: func(ctx context.Context) error {
214+
// Resolve SSM parameters passed as ARNs
215+
if err := ssmresolver.ResolveEnvironmentWithDefaults(ctx); err != nil {
216+
return err
217+
}
218+
return validateConfig()
219+
},
220+
})
221+
}
222+
223+
func handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (Response, error) {
224+
// Lazy-load config with retries (idempotent after first success)
225+
if err := runtime.EnsureLoaded(ctx); err != nil {
226+
return Response{StatusCode: 503, Body: "Service unavailable"}, nil
227+
}
228+
return handleRequest(ctx, req)
229+
}
179230
```
180231

232+
The Runtime auto-detects Lambda environments and adjusts retry settings:
233+
- **HTTP**: 30 retries, 2-second intervals (suitable for startup)
234+
- **Lambda**: 5 retries, 1-second intervals (suitable for cold starts)
235+
181236
## SSM ARN Resolution
182237

183238
For Lambda deployments where secrets are passed as SSM ARNs:

configwait/configwait_test.go

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -358,41 +358,3 @@ func TestReloader_ContextCancellation(t *testing.T) {
358358
t.Error("Reloader did not stop after context cancellation")
359359
}
360360
}
361-
362-
func TestGlobalReloader(t *testing.T) {
363-
// Clear any existing global reloader
364-
SetGlobalReloader(nil)
365-
366-
// TriggerReload should be a no-op when no global reloader is set
367-
TriggerReload() // Should not panic
368-
369-
ctx, cancel := context.WithCancel(context.Background())
370-
defer cancel()
371-
372-
gate := NewReadyGate(nil, nil)
373-
374-
var reloadCount atomic.Int32
375-
reloadFunc := func(ctx context.Context) error {
376-
reloadCount.Add(1)
377-
return nil
378-
}
379-
380-
reloader := NewReloader(ctx, gate, reloadFunc)
381-
reloader.Start()
382-
383-
// Set global reloader
384-
SetGlobalReloader(reloader)
385-
386-
// Now TriggerReload should work
387-
TriggerReload()
388-
389-
// Wait for reload to complete
390-
time.Sleep(50 * time.Millisecond)
391-
392-
if got := reloadCount.Load(); got != 1 {
393-
t.Errorf("Reload count = %d, want 1", got)
394-
}
395-
396-
// Clean up
397-
SetGlobalReloader(nil)
398-
}

configwait/reloader.go

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -106,47 +106,3 @@ func (r *Reloader) doReload() {
106106

107107
log.Infof("[reloader] configuration reloaded successfully")
108108
}
109-
110-
var (
111-
globalReloaderMu sync.RWMutex
112-
globalReloader *Reloader
113-
114-
reloadCounter int64
115-
reloadCounterMu sync.Mutex
116-
)
117-
118-
// SetGlobalReloader sets the global reloader instance.
119-
func SetGlobalReloader(r *Reloader) {
120-
globalReloaderMu.Lock()
121-
defer globalReloaderMu.Unlock()
122-
globalReloader = r
123-
}
124-
125-
// TriggerReload triggers a reload using the global reloader (no-op if unset).
126-
func TriggerReload() {
127-
reloadCounterMu.Lock()
128-
reloadCounter++
129-
reloadCounterMu.Unlock()
130-
131-
globalReloaderMu.RLock()
132-
r := globalReloader
133-
globalReloaderMu.RUnlock()
134-
135-
if r != nil {
136-
r.Trigger()
137-
}
138-
}
139-
140-
// GetReloadCount returns the number of times TriggerReload has been called.
141-
func GetReloadCount() int64 {
142-
reloadCounterMu.Lock()
143-
defer reloadCounterMu.Unlock()
144-
return reloadCounter
145-
}
146-
147-
// ResetReloadCounter resets the reload counter to zero.
148-
func ResetReloadCounter() {
149-
reloadCounterMu.Lock()
150-
defer reloadCounterMu.Unlock()
151-
reloadCounter = 0
152-
}

0 commit comments

Comments
 (0)