diff --git a/README.md b/README.md index a7d5290..0c484f0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) } ``` @@ -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: diff --git a/configwait/configwait_test.go b/configwait/configwait_test.go index 852c948..af39bb0 100644 --- a/configwait/configwait_test.go +++ b/configwait/configwait_test.go @@ -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) -} diff --git a/configwait/reloader.go b/configwait/reloader.go index a1541fd..68f86b8 100644 --- a/configwait/reloader.go +++ b/configwait/reloader.go @@ -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 -} diff --git a/examples/simple/main.go b/examples/simple/main.go index 05bd9f9..d3e391e 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -1,7 +1,8 @@ // Copyright 2025 CruxStack // SPDX-License-Identifier: MIT -// Example demonstrating a GitHub App with webhook handling. +// Example demonstrating a GitHub App with webhook handling using the +// ghappsetup.Runtime for unified lifecycle management. package main import ( @@ -20,7 +21,7 @@ import ( "time" "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" ) @@ -42,32 +43,31 @@ func main() { fmt.Sscanf(p, "%d", &port) } - allowedPaths := []string{"/healthz"} installerEnabled := configstore.InstallerEnabled() + + // Determine allowed paths for the ReadyGate + allowedPaths := []string{"/healthz"} if installerEnabled { allowedPaths = append(allowedPaths, "/setup", "/callback", "/") } - gate := configwait.NewReadyGate(nil, allowedPaths) - mux := http.NewServeMux() - - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - if gate.IsReady() { - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) - } else { - w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("not ready")) - } + // Create the Runtime with unified lifecycle management + runtime, err := ghappsetup.NewRuntime(ghappsetup.Config{ + LoadFunc: func(ctx context.Context) error { return loadConfig(ctx, log) }, + AllowedPaths: allowedPaths, }) + if err != nil { + log.Error("failed to create runtime", "error", err) + os.Exit(1) + } - if installerEnabled { - store, err := configstore.NewFromEnv() - if err != nil { - log.Error("failed to create config store", "error", err) - os.Exit(1) - } + // Set up HTTP routes + mux := http.NewServeMux() + mux.HandleFunc("/healthz", runtime.HealthHandler()) + mux.HandleFunc("/webhook", webhookHandler(log)) + // Set up installer if enabled (using Option B: convenience method) + if installerEnabled { manifest := installer.Manifest{ URL: "https://github.com/cruxstack/github-app-setup-go", Public: false, @@ -81,12 +81,12 @@ func main() { }, } - installerCfg := installer.NewConfigFromEnv() - installerCfg.Store = store - installerCfg.Manifest = manifest - installerCfg.AppDisplayName = "Simple Webhook App" - - installerHandler, err := installer.New(installerCfg) + installerHandler, err := runtime.InstallerHandler(installer.Config{ + Manifest: manifest, + AppDisplayName: "Simple Webhook App", + GitHubURL: configstore.GetEnvDefault("GITHUB_URL", "https://github.com"), + GitHubOrg: os.Getenv("GITHUB_ORG"), + }) if err != nil { log.Error("failed to create installer handler", "error", err) os.Exit(1) @@ -100,16 +100,16 @@ func main() { log.Info("installer enabled, visit /setup to create GitHub App") } - gate.SetHandler(mux) - + // Create server with Runtime's handler (includes ReadyGate) srv := &http.Server{ Addr: fmt.Sprintf(":%d", port), ReadHeaderTimeout: defaultReadHeaderTimeout, - Handler: gate, + Handler: runtime.Handler(mux), } log.Info("starting HTTP server", "port", port, "installer_enabled", installerEnabled) + // Start the HTTP server go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Error("server error", "error", err) @@ -117,28 +117,18 @@ func main() { } }() + // Start configuration loading (blocks until config is loaded) go func() { - waitCfg := configwait.NewConfigFromEnv() - - err := configwait.Wait(ctx, waitCfg, func(ctx context.Context) error { - return loadConfig(ctx, log, mux) - }) - - if err != nil { + if err := runtime.Start(ctx); err != nil { log.Error("failed to load configuration after retries", "error", err) os.Exit(1) } - log.Info("configuration loaded, service is ready") - gate.SetReady() - - reloader := configwait.NewReloader(ctx, gate, func(ctx context.Context) error { - return loadConfig(ctx, log, mux) - }) - configwait.SetGlobalReloader(reloader) - reloader.Start() + // Listen for reload triggers (SIGHUP or installer callback) + done := runtime.ListenForReloads(ctx) log.Info("configuration reloader started (send SIGHUP to reload)") + <-done }() <-ctx.Done() @@ -153,8 +143,8 @@ func main() { } } -// loadConfig loads configuration and sets up the webhook handler. -func loadConfig(_ context.Context, log *slog.Logger, mux *http.ServeMux) error { +// loadConfig loads configuration from environment variables. +func loadConfig(_ context.Context, log *slog.Logger) error { webhookSecret := os.Getenv(configstore.EnvGitHubWebhookSecret) if webhookSecret == "" { return fmt.Errorf("%s is not set", configstore.EnvGitHubWebhookSecret) @@ -166,14 +156,11 @@ func loadConfig(_ context.Context, log *slog.Logger, mux *http.ServeMux) error { } log.Info("loaded GitHub App configuration", "app_id", appID) - - mux.HandleFunc("/webhook", webhookHandler(log, webhookSecret)) - return nil } // webhookHandler returns an HTTP handler that processes GitHub webhooks. -func webhookHandler(log *slog.Logger, secret string) http.HandlerFunc { +func webhookHandler(log *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -188,6 +175,8 @@ func webhookHandler(log *slog.Logger, secret string) http.HandlerFunc { } defer r.Body.Close() + // Get webhook secret from environment (loaded by loadConfig) + secret := os.Getenv(configstore.EnvGitHubWebhookSecret) signature := r.Header.Get("X-Hub-Signature-256") if !validateSignature(body, signature, secret) { log.Warn("webhook signature validation failed", diff --git a/ghappsetup/doc.go b/ghappsetup/doc.go new file mode 100644 index 0000000..d2923a6 --- /dev/null +++ b/ghappsetup/doc.go @@ -0,0 +1,95 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +// Package ghappsetup provides a unified runtime for GitHub App lifecycle +// management. It coordinates configuration loading, readiness gating, and +// hot reloading for both HTTP servers and AWS Lambda functions. +// +// The Runtime type provides a consistent interface across both environments: +// +// - HTTP servers use Start() to block until config loads, Handler() to wrap +// requests with a ReadyGate, and ListenForReloads() for SIGHUP handling. +// +// - Lambda functions use EnsureLoaded() for lazy initialization with retry +// logic on each invocation. +// +// # HTTP Server Usage +// +// For HTTP servers, create a Runtime and use its methods to manage the +// application lifecycle: +// +// runtime, err := ghappsetup.NewRuntime(ghappsetup.Config{ +// LoadFunc: loadConfig, +// AllowedPaths: []string{"/healthz", "/setup", "/callback", "/"}, +// }) +// if err != nil { +// log.Fatal(err) +// } +// +// mux := http.NewServeMux() +// mux.HandleFunc("/healthz", runtime.HealthHandler()) +// mux.HandleFunc("/webhook", webhookHandler) +// +// // Option A: Manual installer wiring +// installerHandler, _ := installer.New(installer.Config{ +// Store: runtime.Store(), +// Manifest: manifest, +// OnReloadNeeded: runtime.ReloadCallback(), +// }) +// +// // Option B: Convenience method (recommended) +// installerHandler, _ := runtime.InstallerHandler(installer.Config{ +// Manifest: manifest, +// }) +// +// mux.Handle("/setup", installerHandler) +// +// srv := &http.Server{Handler: runtime.Handler(mux)} +// go srv.ListenAndServe() +// +// // Block until config loads, then listen for SIGHUP reloads +// runtime.Start(ctx) +// runtime.ListenForReloads(ctx) +// +// # Lambda Usage +// +// For Lambda functions, use EnsureLoaded() at the start of each handler +// invocation: +// +// var runtime *ghappsetup.Runtime +// +// func init() { +// runtime, _ = ghappsetup.NewRuntime(ghappsetup.Config{ +// LoadFunc: func(ctx context.Context) error { +// // Resolve SSM parameters if needed +// if err := ssmresolver.ResolveEnvironmentWithDefaults(ctx); err != nil { +// return err +// } +// return initHandler() +// }, +// }) +// } +// +// func handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (Response, error) { +// if err := runtime.EnsureLoaded(ctx); err != nil { +// return serviceUnavailableResponse(), nil +// } +// return handleRequest(ctx, req) +// } +// +// # Environment Detection +// +// The Runtime automatically detects whether it's running in an HTTP server +// or Lambda environment by checking for the AWS_LAMBDA_FUNCTION_NAME +// environment variable. This affects default retry settings: +// +// - HTTP: 30 retries with 2-second intervals (suitable for startup) +// - Lambda: 5 retries with 1-second intervals (suitable for cold starts) +// +// # Installer Integration +// +// The Runtime integrates with the installer package for GitHub App creation. +// When credentials are saved via the installer, the Runtime's reload callback +// is invoked to trigger configuration reloading. This eliminates the need for +// global state and enables proper dependency injection. +package ghappsetup diff --git a/ghappsetup/installer.go b/ghappsetup/installer.go new file mode 100644 index 0000000..f3a82f2 --- /dev/null +++ b/ghappsetup/installer.go @@ -0,0 +1,39 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +package ghappsetup + +import ( + "net/http" + + "github.com/cruxstack/github-app-setup-go/installer" +) + +// InstallerHandler creates an installer http.Handler with the Runtime's store +// and reload callback pre-configured. This is a convenience method that +// simplifies the common case of wiring up the installer with the runtime. +// +// The returned handler should be mounted at /setup, /setup/, /callback, and / +// paths. For example: +// +// installerHandler, err := runtime.InstallerHandler(installer.Config{ +// Manifest: manifest, +// AppDisplayName: "My App", +// }) +// if err != nil { +// log.Fatal(err) +// } +// mux.Handle("/setup", installerHandler) +// mux.Handle("/setup/", installerHandler) +// mux.Handle("/callback", installerHandler) +// mux.Handle("/", installerHandler) +// +// The Config.Store and Config.OnReloadNeeded fields are automatically set +// by this method and should not be provided in the input config. +func (r *Runtime) InstallerHandler(cfg installer.Config) (http.Handler, error) { + // Set store and reload callback automatically + cfg.Store = r.store + cfg.OnReloadNeeded = r.ReloadCallback() + + return installer.New(cfg) +} diff --git a/ghappsetup/runtime.go b/ghappsetup/runtime.go new file mode 100644 index 0000000..79e8bdb --- /dev/null +++ b/ghappsetup/runtime.go @@ -0,0 +1,202 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +package ghappsetup + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" + + "github.com/cruxstack/github-app-setup-go/configstore" + "github.com/cruxstack/github-app-setup-go/configwait" +) + +const ( + // Environment variable used to detect Lambda runtime. + envLambdaFunctionName = "AWS_LAMBDA_FUNCTION_NAME" + + // Default retry settings for HTTP servers. + defaultHTTPMaxRetries = 30 + defaultHTTPRetryInterval = 2 * time.Second + + // Default retry settings for Lambda functions. + defaultLambdaMaxRetries = 5 + defaultLambdaRetryInterval = 1 * time.Second +) + +// Environment represents the detected runtime environment. +type Environment int + +const ( + // EnvironmentHTTP indicates a long-running HTTP server environment. + EnvironmentHTTP Environment = iota + // EnvironmentLambda indicates an AWS Lambda function environment. + EnvironmentLambda +) + +// LoadFunc is the function called to load application configuration. +// It should return an error if configuration is not yet available, +// which will trigger a retry according to the configured retry policy. +type LoadFunc func(ctx context.Context) error + +// Config configures the Runtime behavior. +type Config struct { + // Store is the credential storage backend. If nil, one will be created + // automatically using configstore.NewFromEnv(). + Store configstore.Store + + // LoadFunc is called to load application configuration. This is required. + // The function should read configuration from environment variables or + // other sources and initialize application state. It will be called + // during startup and on reload triggers. + LoadFunc LoadFunc + + // AllowedPaths specifies HTTP paths that should be served even before + // configuration is loaded. This is typically used for health checks and + // installer endpoints. Only applicable in HTTP environments. + AllowedPaths []string + + // MaxRetries is the maximum number of times to retry loading configuration. + // If zero, defaults are used based on detected environment: + // HTTP: 30 retries, Lambda: 5 retries. + MaxRetries int + + // RetryInterval is the time to wait between retry attempts. + // If zero, defaults are used based on detected environment: + // HTTP: 2 seconds, Lambda: 1 second. + RetryInterval time.Duration +} + +// Runtime coordinates GitHub App configuration loading, readiness gating, +// and hot reloading. It provides a unified interface for both HTTP servers +// and Lambda functions. +type Runtime struct { + config Config + store configstore.Store + gate *configwait.ReadyGate + env Environment + + mu sync.RWMutex + ready bool + reloadCh chan struct{} +} + +// NewRuntime creates a new Runtime with the given configuration. +// It auto-detects the runtime environment (HTTP vs Lambda) and applies +// appropriate defaults for retry behavior. +func NewRuntime(cfg Config) (*Runtime, error) { + if cfg.LoadFunc == nil { + return nil, errors.New("ghappsetup: LoadFunc is required") + } + + // Auto-detect environment + env := detectEnvironment() + + // Apply defaults based on environment + if cfg.MaxRetries == 0 { + if env == EnvironmentLambda { + cfg.MaxRetries = defaultLambdaMaxRetries + } else { + cfg.MaxRetries = defaultHTTPMaxRetries + } + } + if cfg.RetryInterval == 0 { + if env == EnvironmentLambda { + cfg.RetryInterval = defaultLambdaRetryInterval + } else { + cfg.RetryInterval = defaultHTTPRetryInterval + } + } + + // Create store if not provided + store := cfg.Store + if store == nil { + var err error + store, err = configstore.NewFromEnv() + if err != nil { + return nil, fmt.Errorf("ghappsetup: failed to create store: %w", err) + } + } + + // Create ready gate for HTTP environments + var gate *configwait.ReadyGate + if env == EnvironmentHTTP { + gate = configwait.NewReadyGate(nil, cfg.AllowedPaths) + } + + return &Runtime{ + config: cfg, + store: store, + gate: gate, + env: env, + reloadCh: make(chan struct{}, 1), + }, nil +} + +// Store returns the credential storage backend used by this Runtime. +// This is useful when manually wiring up the installer. +func (r *Runtime) Store() configstore.Store { + return r.store +} + +// Environment returns the detected runtime environment. +func (r *Runtime) Environment() Environment { + return r.env +} + +// IsReady returns true if configuration has been successfully loaded. +func (r *Runtime) IsReady() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.ready +} + +// setReady marks the runtime as ready and updates the HTTP gate if present. +func (r *Runtime) setReady(ready bool) { + r.mu.Lock() + r.ready = ready + r.mu.Unlock() + + if ready && r.gate != nil { + r.gate.SetReady() + } +} + +// Reload triggers a configuration reload by calling LoadFunc. +// This is safe to call from multiple goroutines; concurrent reload +// requests are coalesced. +func (r *Runtime) Reload(ctx context.Context) error { + return r.config.LoadFunc(ctx) +} + +// ReloadCallback returns a function suitable for use as installer.Config.OnReloadNeeded. +// The returned function triggers an asynchronous reload. +func (r *Runtime) ReloadCallback() func() { + return func() { + select { + case r.reloadCh <- struct{}{}: + default: + // Reload already pending + } + } +} + +// waitConfig returns a configwait.Config based on the Runtime configuration. +func (r *Runtime) waitConfig() configwait.Config { + return configwait.Config{ + MaxRetries: r.config.MaxRetries, + RetryInterval: r.config.RetryInterval, + } +} + +// detectEnvironment checks for Lambda environment indicators. +func detectEnvironment() Environment { + if os.Getenv(envLambdaFunctionName) != "" { + return EnvironmentLambda + } + return EnvironmentHTTP +} diff --git a/ghappsetup/runtime_http.go b/ghappsetup/runtime_http.go new file mode 100644 index 0000000..e0a4609 --- /dev/null +++ b/ghappsetup/runtime_http.go @@ -0,0 +1,116 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +package ghappsetup + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/cruxstack/github-app-setup-go/configwait" +) + +// Start blocks until configuration is successfully loaded, then marks the +// runtime as ready. It uses the configured retry policy to attempt loading. +// Returns an error if configuration cannot be loaded after all retries. +// +// This method is intended for HTTP server environments. For Lambda, use +// EnsureLoaded instead. +func (r *Runtime) Start(ctx context.Context) error { + err := configwait.Wait(ctx, r.waitConfig(), configwait.LoadFunc(r.config.LoadFunc)) + if err != nil { + return err + } + r.setReady(true) + return nil +} + +// StartAsync begins configuration loading in the background and returns +// immediately. The returned channel receives the result of the loading +// operation - nil on success or an error if loading failed after all retries. +// The channel is closed after sending the result. +// +// This method is intended for HTTP server environments where you want to +// start serving immediately (for health checks and installer endpoints) +// while configuration loads in the background. +func (r *Runtime) StartAsync(ctx context.Context) <-chan error { + errCh := make(chan error, 1) + go func() { + defer close(errCh) + errCh <- r.Start(ctx) + }() + return errCh +} + +// Handler wraps the given http.Handler with a ReadyGate that returns 503 +// Service Unavailable for requests to non-allowed paths before the runtime +// is ready. Paths specified in Config.AllowedPaths are always forwarded +// to the inner handler. +// +// The returned handler should be used as the server's main handler. +func (r *Runtime) Handler(inner http.Handler) http.Handler { + if r.gate == nil { + // No gate (e.g., Lambda environment) - return inner directly + return inner + } + r.gate.SetHandler(inner) + return r.gate +} + +// ListenForReloads starts listening for SIGHUP signals and reload triggers +// from ReloadCallback. When a reload is triggered, LoadFunc is called. +// The returned channel is closed when the context is canceled. +// +// This should be called after Start() completes successfully. +func (r *Runtime) ListenForReloads(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + + // Set up SIGHUP signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP) + + go func() { + defer close(done) + defer signal.Stop(sigCh) + + for { + select { + case <-ctx.Done(): + return + case <-sigCh: + r.doReload(ctx) + case <-r.reloadCh: + r.doReload(ctx) + } + } + }() + + return done +} + +// doReload performs the actual reload operation. +func (r *Runtime) doReload(ctx context.Context) { + if err := r.config.LoadFunc(ctx); err != nil { + // Log error but don't crash - reload failures are non-fatal + // The application continues running with the previous configuration + return + } +} + +// HealthHandler returns an http.HandlerFunc that reports the runtime's +// readiness status. It returns 200 OK with body "ok" when ready, or +// 503 Service Unavailable with body "not ready" when not ready. +func (r *Runtime) HealthHandler() http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if r.IsReady() { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("not ready")) + } + } +} diff --git a/ghappsetup/runtime_http_test.go b/ghappsetup/runtime_http_test.go new file mode 100644 index 0000000..65f36b6 --- /dev/null +++ b/ghappsetup/runtime_http_test.go @@ -0,0 +1,283 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +package ghappsetup + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/cruxstack/github-app-setup-go/configstore" +) + +func TestRuntime_Start_Success(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var called atomic.Bool + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { + called.Store(true) + return nil + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + err = runtime.Start(ctx) + if err != nil { + t.Errorf("Start() error = %v", err) + } + + if !called.Load() { + t.Error("Start() should call LoadFunc") + } + + if !runtime.IsReady() { + t.Error("IsReady() should be true after Start()") + } +} + +func TestRuntime_Start_RetryThenSuccess(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var callCount atomic.Int32 + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { + count := callCount.Add(1) + if count < 3 { + return errors.New("not ready") + } + return nil + }, + MaxRetries: 5, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + err = runtime.Start(ctx) + if err != nil { + t.Errorf("Start() error = %v", err) + } + + if callCount.Load() != 3 { + t.Errorf("LoadFunc called %d times, want 3", callCount.Load()) + } +} + +func TestRuntime_Start_MaxRetriesExceeded(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + expectedErr := errors.New("always fail") + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { + return expectedErr + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + err = runtime.Start(ctx) + if err != expectedErr { + t.Errorf("Start() error = %v, want %v", err, expectedErr) + } + + if runtime.IsReady() { + t.Error("IsReady() should be false after failed Start()") + } +} + +func TestRuntime_StartAsync(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { + return nil + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + errCh := runtime.StartAsync(ctx) + + select { + case err := <-errCh: + if err != nil { + t.Errorf("StartAsync() error = %v", err) + } + case <-time.After(1 * time.Second): + t.Error("StartAsync() did not complete in time") + } + + if !runtime.IsReady() { + t.Error("IsReady() should be true after StartAsync()") + } +} + +func TestRuntime_Handler_GatesRequests(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + AllowedPaths: []string{"/healthz"}, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + handler := runtime.Handler(inner) + + // Before ready, non-allowed paths return 503 + req := httptest.NewRequest(http.MethodGet, "/api/data", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("Status = %d, want %d before ready", rec.Code, http.StatusServiceUnavailable) + } + + // Allowed paths pass through + req = httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Status = %d, want %d for allowed path", rec.Code, http.StatusOK) + } + + // After ready, all paths pass through + runtime.setReady(true) + + req = httptest.NewRequest(http.MethodGet, "/api/data", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Status = %d, want %d after ready", rec.Code, http.StatusOK) + } +} + +func TestRuntime_HealthHandler(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + handler := runtime.HealthHandler() + + // Not ready + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("Status = %d, want %d when not ready", rec.Code, http.StatusServiceUnavailable) + } + if rec.Body.String() != "not ready" { + t.Errorf("Body = %q, want %q", rec.Body.String(), "not ready") + } + + // After ready + runtime.setReady(true) + + req = httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec = httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Status = %d, want %d when ready", rec.Code, http.StatusOK) + } + if rec.Body.String() != "ok" { + t.Errorf("Body = %q, want %q", rec.Body.String(), "ok") + } +} + +func TestRuntime_ListenForReloads(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var reloadCount atomic.Int32 + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { + reloadCount.Add(1) + return nil + }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := runtime.ListenForReloads(ctx) + + // Trigger reload via callback + callback := runtime.ReloadCallback() + callback() + + // Wait for reload to process + time.Sleep(50 * time.Millisecond) + + if reloadCount.Load() != 1 { + t.Errorf("Reload count = %d, want 1", reloadCount.Load()) + } + + // Cancel context should stop listener + cancel() + + select { + case <-done: + // Good, listener stopped + case <-time.After(100 * time.Millisecond): + t.Error("ListenForReloads did not stop after context cancellation") + } +} + +// mockStore for HTTP tests +type httpMockStore struct{} + +func (m *httpMockStore) Save(ctx context.Context, creds *configstore.AppCredentials) error { + return nil +} + +func (m *httpMockStore) Status(ctx context.Context) (*configstore.InstallerStatus, error) { + return nil, nil +} + +func (m *httpMockStore) DisableInstaller(ctx context.Context) error { + return nil +} diff --git a/ghappsetup/runtime_lambda.go b/ghappsetup/runtime_lambda.go new file mode 100644 index 0000000..b9af7c1 --- /dev/null +++ b/ghappsetup/runtime_lambda.go @@ -0,0 +1,163 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +package ghappsetup + +import ( + "context" + "sync" + "time" + + "github.com/chainguard-dev/clog" +) + +// lambdaState tracks Lambda-specific initialization state. +type lambdaState struct { + mu sync.Mutex + loading bool + loaded bool + lastError error +} + +var lambdaStates = struct { + mu sync.Mutex + states map[*Runtime]*lambdaState +}{ + states: make(map[*Runtime]*lambdaState), +} + +func (r *Runtime) getLambdaState() *lambdaState { + lambdaStates.mu.Lock() + defer lambdaStates.mu.Unlock() + + if state, ok := lambdaStates.states[r]; ok { + return state + } + state := &lambdaState{} + lambdaStates.states[r] = state + return state +} + +// EnsureLoaded ensures that configuration has been loaded, performing +// lazy initialization if needed. This method is idempotent and safe to +// call from multiple goroutines. +// +// On first call, it attempts to load configuration with retry logic. +// Subsequent calls return immediately if already loaded, or return the +// last error if loading failed. +// +// This method is intended for Lambda environments where configuration +// loading happens lazily on first request. For HTTP servers, use Start instead. +// +// Example usage in a Lambda handler: +// +// func handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (Response, error) { +// if err := runtime.EnsureLoaded(ctx); err != nil { +// return serviceUnavailableResponse(), nil +// } +// return handleRequest(ctx, req) +// } +func (r *Runtime) EnsureLoaded(ctx context.Context) error { + state := r.getLambdaState() + + // Fast path: already loaded + state.mu.Lock() + if state.loaded { + state.mu.Unlock() + return nil + } + + // Check if another goroutine is loading + if state.loading { + state.mu.Unlock() + // Wait and retry - another goroutine is loading + return r.waitForLoad(ctx, state) + } + + // We're the loader + state.loading = true + state.mu.Unlock() + + err := r.loadWithRetry(ctx) + + state.mu.Lock() + state.loading = false + if err == nil { + state.loaded = true + r.setReady(true) + } else { + state.lastError = err + } + state.mu.Unlock() + + return err +} + +// waitForLoad waits for another goroutine to finish loading. +func (r *Runtime) waitForLoad(ctx context.Context, state *lambdaState) error { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + state.mu.Lock() + if !state.loading { + loaded := state.loaded + lastErr := state.lastError + state.mu.Unlock() + + if loaded { + return nil + } + return lastErr + } + state.mu.Unlock() + } + } +} + +// loadWithRetry attempts to load configuration with retry logic. +func (r *Runtime) loadWithRetry(ctx context.Context) error { + log := clog.FromContext(ctx) + var lastErr error + + for attempt := 1; attempt <= r.config.MaxRetries; attempt++ { + if err := r.config.LoadFunc(ctx); err != nil { + lastErr = err + log.Warnf("[ghappsetup] attempt %d/%d failed: %v", attempt, r.config.MaxRetries, err) + + if attempt < r.config.MaxRetries { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(r.config.RetryInterval): + } + } + } else { + if attempt > 1 { + log.Infof("[ghappsetup] configuration loaded successfully after %d attempts", attempt) + } + return nil + } + } + + return lastErr +} + +// ResetLoadState resets the Lambda loading state, allowing EnsureLoaded to +// attempt loading again. This is primarily useful for testing. +func (r *Runtime) ResetLoadState() { + state := r.getLambdaState() + state.mu.Lock() + defer state.mu.Unlock() + state.loaded = false + state.loading = false + state.lastError = nil + + r.mu.Lock() + r.ready = false + r.mu.Unlock() +} diff --git a/ghappsetup/runtime_lambda_test.go b/ghappsetup/runtime_lambda_test.go new file mode 100644 index 0000000..7b1e51c --- /dev/null +++ b/ghappsetup/runtime_lambda_test.go @@ -0,0 +1,279 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +package ghappsetup + +import ( + "context" + "errors" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/cruxstack/github-app-setup-go/configstore" +) + +func TestRuntime_EnsureLoaded_Success(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var called atomic.Bool + runtime, err := NewRuntime(Config{ + Store: &lambdaMockStore{}, + LoadFunc: func(ctx context.Context) error { + called.Store(true) + return nil + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + err = runtime.EnsureLoaded(ctx) + if err != nil { + t.Errorf("EnsureLoaded() error = %v", err) + } + + if !called.Load() { + t.Error("EnsureLoaded() should call LoadFunc") + } + + if !runtime.IsReady() { + t.Error("IsReady() should be true after EnsureLoaded()") + } +} + +func TestRuntime_EnsureLoaded_Idempotent(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var callCount atomic.Int32 + runtime, err := NewRuntime(Config{ + Store: &lambdaMockStore{}, + LoadFunc: func(ctx context.Context) error { + callCount.Add(1) + return nil + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + + // Call EnsureLoaded multiple times + for i := 0; i < 5; i++ { + err = runtime.EnsureLoaded(ctx) + if err != nil { + t.Errorf("EnsureLoaded() call %d error = %v", i, err) + } + } + + // LoadFunc should only be called once + if callCount.Load() != 1 { + t.Errorf("LoadFunc called %d times, want 1", callCount.Load()) + } +} + +func TestRuntime_EnsureLoaded_RetryThenSuccess(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var callCount atomic.Int32 + runtime, err := NewRuntime(Config{ + Store: &lambdaMockStore{}, + LoadFunc: func(ctx context.Context) error { + count := callCount.Add(1) + if count < 3 { + return errors.New("not ready") + } + return nil + }, + MaxRetries: 5, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + err = runtime.EnsureLoaded(ctx) + if err != nil { + t.Errorf("EnsureLoaded() error = %v", err) + } + + if callCount.Load() != 3 { + t.Errorf("LoadFunc called %d times, want 3", callCount.Load()) + } + + if !runtime.IsReady() { + t.Error("IsReady() should be true after successful retry") + } +} + +func TestRuntime_EnsureLoaded_MaxRetriesExceeded(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + expectedErr := errors.New("always fail") + runtime, err := NewRuntime(Config{ + Store: &lambdaMockStore{}, + LoadFunc: func(ctx context.Context) error { + return expectedErr + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + err = runtime.EnsureLoaded(ctx) + if err != expectedErr { + t.Errorf("EnsureLoaded() error = %v, want %v", err, expectedErr) + } + + if runtime.IsReady() { + t.Error("IsReady() should be false after failed loading") + } +} + +func TestRuntime_EnsureLoaded_ContextCancellation(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &lambdaMockStore{}, + LoadFunc: func(ctx context.Context) error { + return errors.New("not ready") + }, + MaxRetries: 100, + RetryInterval: 100 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + err = runtime.EnsureLoaded(ctx) + if err != context.Canceled { + t.Errorf("EnsureLoaded() error = %v, want %v", err, context.Canceled) + } +} + +func TestRuntime_ResetLoadState(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var callCount atomic.Int32 + runtime, err := NewRuntime(Config{ + Store: &lambdaMockStore{}, + LoadFunc: func(ctx context.Context) error { + callCount.Add(1) + return nil + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + + // First load + err = runtime.EnsureLoaded(ctx) + if err != nil { + t.Errorf("First EnsureLoaded() error = %v", err) + } + + if callCount.Load() != 1 { + t.Errorf("LoadFunc called %d times, want 1", callCount.Load()) + } + + // Reset and load again + runtime.ResetLoadState() + + if runtime.IsReady() { + t.Error("IsReady() should be false after ResetLoadState()") + } + + err = runtime.EnsureLoaded(ctx) + if err != nil { + t.Errorf("Second EnsureLoaded() error = %v", err) + } + + if callCount.Load() != 2 { + t.Errorf("LoadFunc called %d times, want 2 after reset", callCount.Load()) + } +} + +func TestRuntime_EnsureLoaded_ConcurrentCalls(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + var callCount atomic.Int32 + runtime, err := NewRuntime(Config{ + Store: &lambdaMockStore{}, + LoadFunc: func(ctx context.Context) error { + callCount.Add(1) + // Simulate slow loading + time.Sleep(50 * time.Millisecond) + return nil + }, + MaxRetries: 3, + RetryInterval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + ctx := context.Background() + + // Start multiple concurrent EnsureLoaded calls + done := make(chan error, 10) + for i := 0; i < 10; i++ { + go func() { + done <- runtime.EnsureLoaded(ctx) + }() + } + + // Wait for all to complete + for i := 0; i < 10; i++ { + if err := <-done; err != nil { + t.Errorf("Concurrent EnsureLoaded() error = %v", err) + } + } + + // LoadFunc should only be called once despite concurrent calls + if callCount.Load() != 1 { + t.Errorf("LoadFunc called %d times, want 1", callCount.Load()) + } +} + +// lambdaMockStore for Lambda tests +type lambdaMockStore struct{} + +func (m *lambdaMockStore) Save(ctx context.Context, creds *configstore.AppCredentials) error { + return nil +} + +func (m *lambdaMockStore) Status(ctx context.Context) (*configstore.InstallerStatus, error) { + return nil, nil +} + +func (m *lambdaMockStore) DisableInstaller(ctx context.Context) error { + return nil +} diff --git a/ghappsetup/runtime_test.go b/ghappsetup/runtime_test.go new file mode 100644 index 0000000..ded82b7 --- /dev/null +++ b/ghappsetup/runtime_test.go @@ -0,0 +1,232 @@ +// Copyright 2025 CruxStack +// SPDX-License-Identifier: MIT + +package ghappsetup + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/cruxstack/github-app-setup-go/configstore" +) + +func TestNewRuntime_RequiresLoadFunc(t *testing.T) { + _, err := NewRuntime(Config{}) + if err == nil { + t.Error("NewRuntime() should return error when LoadFunc is nil") + } +} + +func TestNewRuntime_CreatesStoreWhenNil(t *testing.T) { + // Set up environment for LocalEnvFileStore + tempDir := t.TempDir() + envFile := tempDir + "/.env" + os.Setenv("STORAGE_MODE", "envfile") + os.Setenv("STORAGE_DIR", envFile) + defer os.Unsetenv("STORAGE_MODE") + defer os.Unsetenv("STORAGE_DIR") + + runtime, err := NewRuntime(Config{ + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + if runtime.Store() == nil { + t.Error("Runtime.Store() should not be nil when auto-created") + } +} + +func TestNewRuntime_UsesProvidedStore(t *testing.T) { + store := &mockStore{} + + runtime, err := NewRuntime(Config{ + Store: store, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + if runtime.Store() != store { + t.Error("Runtime.Store() should return the provided store") + } +} + +func TestNewRuntime_DetectsHTTPEnvironment(t *testing.T) { + // Ensure Lambda env var is not set + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + if runtime.Environment() != EnvironmentHTTP { + t.Errorf("Environment() = %v, want EnvironmentHTTP", runtime.Environment()) + } +} + +func TestNewRuntime_DetectsLambdaEnvironment(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + if runtime.Environment() != EnvironmentLambda { + t.Errorf("Environment() = %v, want EnvironmentLambda", runtime.Environment()) + } +} + +func TestNewRuntime_HTTPDefaults(t *testing.T) { + os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + if runtime.config.MaxRetries != defaultHTTPMaxRetries { + t.Errorf("MaxRetries = %d, want %d", runtime.config.MaxRetries, defaultHTTPMaxRetries) + } + if runtime.config.RetryInterval != defaultHTTPRetryInterval { + t.Errorf("RetryInterval = %v, want %v", runtime.config.RetryInterval, defaultHTTPRetryInterval) + } +} + +func TestNewRuntime_LambdaDefaults(t *testing.T) { + os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "test-function") + defer os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + if runtime.config.MaxRetries != defaultLambdaMaxRetries { + t.Errorf("MaxRetries = %d, want %d", runtime.config.MaxRetries, defaultLambdaMaxRetries) + } + if runtime.config.RetryInterval != defaultLambdaRetryInterval { + t.Errorf("RetryInterval = %v, want %v", runtime.config.RetryInterval, defaultLambdaRetryInterval) + } +} + +func TestRuntime_IsReady(t *testing.T) { + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + if runtime.IsReady() { + t.Error("IsReady() should be false initially") + } + + runtime.setReady(true) + + if !runtime.IsReady() { + t.Error("IsReady() should be true after setReady(true)") + } +} + +func TestRuntime_ReloadCallback(t *testing.T) { + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { return nil }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + callback := runtime.ReloadCallback() + + // Callback should send to reloadCh + callback() + + select { + case <-runtime.reloadCh: + // Good, received signal + case <-time.After(100 * time.Millisecond): + t.Error("ReloadCallback() did not send to reloadCh") + } + + // Calling multiple times should not block (buffered channel) + callback() + callback() +} + +func TestRuntime_Reload(t *testing.T) { + var called bool + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { + called = true + return nil + }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + err = runtime.Reload(context.Background()) + if err != nil { + t.Errorf("Reload() error = %v", err) + } + if !called { + t.Error("Reload() should call LoadFunc") + } +} + +func TestRuntime_Reload_Error(t *testing.T) { + expectedErr := errors.New("load failed") + runtime, err := NewRuntime(Config{ + Store: &mockStore{}, + LoadFunc: func(ctx context.Context) error { + return expectedErr + }, + }) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + + err = runtime.Reload(context.Background()) + if err != expectedErr { + t.Errorf("Reload() error = %v, want %v", err, expectedErr) + } +} + +// mockStore is a minimal Store implementation for testing. +type mockStore struct{} + +func (m *mockStore) Save(ctx context.Context, creds *configstore.AppCredentials) error { + return nil +} + +func (m *mockStore) Status(ctx context.Context) (*configstore.InstallerStatus, error) { + return nil, nil +} + +func (m *mockStore) DisableInstaller(ctx context.Context) error { + return nil +} diff --git a/installer/installer.go b/installer/installer.go index 140c2c3..923d5f4 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -21,7 +21,6 @@ import ( "github.com/chainguard-dev/clog" "github.com/cruxstack/github-app-setup-go/configstore" - "github.com/cruxstack/github-app-setup-go/configwait" ) //go:embed templates/* @@ -50,6 +49,11 @@ type Config struct { RedirectURL string WebhookURL string OnCredentialsSaved CredentialsSavedFunc + + // OnReloadNeeded is called after credentials are saved to trigger + // a configuration reload. This should be wired to the Runtime's + // ReloadCallback() or a custom reload function. + OnReloadNeeded func() } // NewConfigFromEnv creates a Config from environment variables. @@ -281,8 +285,10 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { log.Infof("[installer] successfully created github app: slug=%s app_id=%d", creds.AppSlug, creds.AppID) - log.Infof("[installer] triggering configuration reload") - configwait.TriggerReload() + if h.config.OnReloadNeeded != nil { + log.Infof("[installer] triggering configuration reload") + h.config.OnReloadNeeded() + } data := h.successDataFromCreds(creds) h.renderSuccess(w, r, data) diff --git a/integration/scenario.go b/integration/scenario.go index e43aeb4..ef88251 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -15,13 +15,13 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" "gopkg.in/yaml.v3" "github.com/cruxstack/github-app-setup-go/configstore" - "github.com/cruxstack/github-app-setup-go/configwait" "github.com/cruxstack/github-app-setup-go/installer" ) @@ -176,8 +176,8 @@ func (r *ScenarioRunner) Run(scenario Scenario) { } } - // Reset reload counter - configwait.ResetReloadCounter() + // Track reload calls using atomic counter + var reloadCount atomic.Int64 // Create installer handler cfg := installer.Config{ @@ -195,6 +195,11 @@ func (r *ScenarioRunner) Run(scenario Scenario) { cfg.WebhookURL = scenario.Config.WebhookURL } + // Set up reload callback to track reload calls + cfg.OnReloadNeeded = func() { + reloadCount.Add(1) + } + handler, err := installer.New(cfg) if err != nil { t.Fatalf("create installer: %v", err) @@ -290,7 +295,7 @@ func (r *ScenarioRunner) Run(scenario Scenario) { // Verify reload was triggered if expected if scenario.ExpectReload { - count := configwait.GetReloadCount() + count := reloadCount.Load() if count == 0 { t.Errorf("expected reload to be triggered, but it was not") }