diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31e53dd..d765107 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,13 @@ jobs: # oneshot, and cbox-init exits non-zero when a oneshot fails. docker run --rm --name cbox-init-pid1 cbox-init-pid1 + - name: Runtime PHP-FPM autotuner e2e + if: matrix.distro == 'debian' + # cbox-init as PID 1 supervising real php-fpm, with the embedded fpm-tune + # loop: discover, enable status, size, resize under load, reload via + # SIGUSR2 (not a restart). The script's exit code is the result. + run: bash tests/integration/e2e-fpm-tune.sh + - name: Run integration tests - ${{ matrix.distro }} run: | docker build \ diff --git a/Makefile b/Makefile index e848bb2..95e883c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-all clean test test-all test-integration bench coverage lint deps install dev help \ +.PHONY: build build-all clean test test-all test-integration test-e2e-fpm-tune bench coverage lint deps install dev help \ check check-configs cover-check fmt fmt-check vet vulncheck sbom sbom-check license-check # Build variables @@ -77,6 +77,10 @@ test-integration: done @echo "โœ… All integration tests passed" +test-e2e-fpm-tune: build-all + @echo "๐Ÿงช Running the runtime PHP-FPM autotuner e2e (php-fpm container)..." + @bash tests/integration/e2e-fpm-tune.sh + # Run benchmarks bench: @echo "โšก Running benchmarks..." diff --git a/cmd/cbox-init/cmd_test.go b/cmd/cbox-init/cmd_test.go index 0a9c22b..fb10f14 100644 --- a/cmd/cbox-init/cmd_test.go +++ b/cmd/cbox-init/cmd_test.go @@ -7361,7 +7361,7 @@ func TestPerformGracefulShutdownDirect(t *testing.T) { } done <- true }() - performGracefulShutdown(cfg, pm, nil, nil, auditLog, "test") + performGracefulShutdown(cfg, pm, nil, nil, nil, auditLog, "test") }() select { @@ -8107,7 +8107,7 @@ func TestPerformGracefulShutdownWithServers(t *testing.T) { } done <- true }() - performGracefulShutdown(cfg, pm, nil, nil, auditLog, "test_signal") + performGracefulShutdown(cfg, pm, nil, nil, nil, auditLog, "test_signal") }() select { diff --git a/cmd/cbox-init/fpmtune.go b/cmd/cbox-init/fpmtune.go new file mode 100644 index 0000000..92b987c --- /dev/null +++ b/cmd/cbox-init/fpmtune.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + + "github.com/cboxdk/fpm-tune/plan" + "github.com/cboxdk/fpm-tune/serve" + "github.com/cboxdk/fpm-tune/state" + + "github.com/cboxdk/init/internal/config" +) + +// The p95 hybrid is the intended default sizing basis: size on the 95th +// percentile of the worker memory distribution plus a small margin, floored so it +// still reacts to a real jump in one scrape. It mirrors fpm-tune's own default +// (`serve -sizing p95`); the zero value would instead be the pure peak-follower, +// which sizes forever on the worst worker ever seen and consistently over-provisions. +const ( + fpmSizingPercentile = 0.95 + fpmSizingMargin = 0.10 +) + +// startFPMTune starts the embedded runtime PHP-FPM autotuner as a background +// loop when it is enabled. It returns a stop function that halts the loop and +// waits for it to finish โ€” releasing its state-file lock and saving learned +// baselines. Call the stop function BEFORE php-fpm is torn down, because the loop +// rewrites php-fpm's pool configuration and reloads it; a nil stop function means +// the autotuner was disabled and nothing was started. +// +// The loop embeds github.com/cboxdk/fpm-tune. It does its own discovery of the +// running master (scan-and-retry), so starting it here, right after the processes +// are up, is fine even if php-fpm is still coming up. Its config is loaded per +// round, so a pool's boot-time pm.max_children (set by the calculator before +// php-fpm started) is the seed it refines, not something it fights. +func startFPMTune(ctx context.Context, cfg *config.Config, log *slog.Logger) (func(), error) { + ft := cfg.Global.FPMTune + if ft == nil || !ft.Enabled { + return nil, nil + } + + sc := serve.Config{ + // apply is the default and the point of embedding it; advisory is the only + // opt-out. The empty paths (state, backup, drop-in) are resolved by + // fpm-tune's own Config.Defaults() exactly as the standalone tool resolves them. + Apply: ft.Mode != "advisory", + Interval: ft.Interval, + DropInDir: ft.DropInDir, + StatePath: ft.StatePath, + BackupDir: ft.BackupDir, + MetricsAddr: ft.MetricsAddr, + RecommendPath: ft.RecommendPath, + ReserveFraction: ft.ReserveFraction, + Workload: resolveFPMWorkload(ft.Workload, log), + Version: version, // reported on the loop's /history.json + + StateOptions: state.Options{ + Sizing: state.Sizing{Percentile: fpmSizingPercentile, Margin: fpmSizingMargin}, + }, + } + + loop, err := serve.New(sc, log) + if err != nil { + return nil, fmt.Errorf("fpm-tune: %w", err) + } + + // The loop gets its own context so shutdown can stop it independently: the + // serve context is cancelled only after graceful shutdown has already run, and + // a tuner that keeps rewriting and reloading php-fpm while it is being drained + // is the one thing we must not allow. + loopCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + defer close(done) + if err := loop.Run(loopCtx); err != nil { + log.Error("Runtime PHP-FPM autotuner exited with error", "error", err) + } + }() + + log.Info("Runtime PHP-FPM autotuner started", + "mode", ft.Mode, + "apply", sc.Apply, + "interval", ft.Interval, + "metrics", ft.MetricsAddr, + ) + + stop := func() { + cancel() + <-done // Run's deferred Close() releases the state lock and saves baselines. + } + + return stop, nil +} + +// resolveFPMWorkload maps the configured workload name to a class, warning on an +// unknown name rather than failing โ€” the same forgiving behaviour as the fpm-tune +// CLI, so a typo degrades to the safe web default instead of stopping the daemon. +func resolveFPMWorkload(name string, log *slog.Logger) plan.Workload { + if name == "" { + return plan.WorkloadWeb + } + w, ok := plan.WorkloadByName(name, plan.WorkloadWeb) + if !ok { + log.Warn("Unknown fpm_tune.workload; using the web default", "name", name) + } + + return w +} diff --git a/cmd/cbox-init/fpmtune_test.go b/cmd/cbox-init/fpmtune_test.go new file mode 100644 index 0000000..3f8b8e7 --- /dev/null +++ b/cmd/cbox-init/fpmtune_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + "github.com/cboxdk/init/internal/config" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// TestStartFPMTuneDisabled: a missing or disabled block starts nothing and +// returns a nil stop, so serve can call it unconditionally. +func TestStartFPMTuneDisabled(t *testing.T) { + for _, ft := range []*config.FPMTuneConfig{nil, {Enabled: false}} { + cfg := &config.Config{Global: config.GlobalConfig{FPMTune: ft}} + stop, err := startFPMTune(context.Background(), cfg, discardLogger()) + if err != nil { + t.Fatalf("disabled autotuner returned an error: %v", err) + } + if stop != nil { + t.Error("disabled autotuner returned a non-nil stop function") + } + } +} + +// TestStartFPMTuneStartsAndStops drives the real embedded loop (advisory, so it +// never writes php-fpm config) against temp paths, then stops it. The stop must +// return promptly โ€” it releases the state lock and saves baselines. Guarded by a +// timeout so a hang fails the test instead of blocking the suite. +func TestStartFPMTuneStartsAndStops(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Global: config.GlobalConfig{ + FPMTune: &config.FPMTuneConfig{ + Enabled: true, + Mode: "advisory", // never writes php-fpm config in a test + Interval: time.Second, + StatePath: filepath.Join(dir, "state.json"), + BackupDir: filepath.Join(dir, "backup"), + // MetricsAddr empty: bind no port. + }, + }, + } + + stop, err := startFPMTune(context.Background(), cfg, discardLogger()) + if err != nil { + t.Fatalf("startFPMTune: %v", err) + } + if stop == nil { + t.Fatal("enabled autotuner returned a nil stop function") + } + + done := make(chan struct{}) + go func() { + stop() + close(done) + }() + + select { + case <-done: + case <-time.After(20 * time.Second): + t.Fatal("stop() did not return; the loop is not shutting down cleanly") + } + + // The state lock is released, so a second loop on the same state path starts. + stop2, err := startFPMTune(context.Background(), cfg, discardLogger()) + if err != nil { + t.Fatalf("second start after stop failed (lock not released?): %v", err) + } + if stop2 != nil { + stop2() + } +} diff --git a/cmd/cbox-init/serve.go b/cmd/cbox-init/serve.go index b70f7e9..e74b6dc 100644 --- a/cmd/cbox-init/serve.go +++ b/cmd/cbox-init/serve.go @@ -388,6 +388,15 @@ func runServe(cmd *cobra.Command, args []string) { defer func() { _ = configWatcher.Stop() }() } + // Runtime PHP-FPM autotuner (embedded fpm-tune). Started last, after every + // startup step that can os.Exit, so its stop is never skipped. Non-critical: + // if it cannot start (for example a second copy already holds the state lock), + // php-fpm keeps its boot-time size and the container runs on. + stopFPMTune, err := startFPMTune(ctx, cfg, log) + if err != nil { + slog.Warn("Runtime PHP-FPM autotuner not started", "error", err) + } + // Main event loop - handles shutdown signals and config reloads var shutdownReason string for { @@ -411,7 +420,7 @@ func runServe(cmd *cobra.Command, args []string) { } // Graceful shutdown for other reasons (signal, all processes dead) - performGracefulShutdown(cfg, pm, apiServer, metricsServer, auditLogger, shutdownReason) + performGracefulShutdown(cfg, pm, apiServer, metricsServer, stopFPMTune, auditLogger, shutdownReason) break } @@ -800,7 +809,7 @@ func waitForShutdownOrReload( } // performGracefulShutdown gracefully shuts down all components -func performGracefulShutdown(cfg *config.Config, pm *process.Manager, apiServer *api.Server, metricsServer *metrics.Server, auditLogger *audit.Logger, reason string) { +func performGracefulShutdown(cfg *config.Config, pm *process.Manager, apiServer *api.Server, metricsServer *metrics.Server, stopFPMTune func(), auditLogger *audit.Logger, reason string) { shutdownCtx, shutdownCancel := context.WithTimeout( context.Background(), time.Duration(cfg.Global.ShutdownTimeout)*time.Second, @@ -812,6 +821,14 @@ func performGracefulShutdown(cfg *config.Config, pm *process.Manager, apiServer "timeout", cfg.Global.ShutdownTimeout, ) + // Stop the runtime autotuner FIRST, before php-fpm is drained: it rewrites + // php-fpm's config and reloads it, so it must not be acting on a master that + // is being torn down. The stop waits for the loop to finish its round, save + // baselines, and release its lock. + if stopFPMTune != nil { + stopFPMTune() + } + // Shutdown process manager if err := pm.Shutdown(shutdownCtx); err != nil { slog.Error("Shutdown completed with errors", "error", err) diff --git a/configs/examples/php-fpm-autotune.yaml b/configs/examples/php-fpm-autotune.yaml new file mode 100644 index 0000000..b6685ba --- /dev/null +++ b/configs/examples/php-fpm-autotune.yaml @@ -0,0 +1,43 @@ +# Runtime PHP-FPM autotuning +# +# cbox-init sizes php-fpm twice, at two different moments: +# +# 1. Boot: the autotune profile computes a starting pm.max_children from the +# container's memory limit and templates it into www.conf before php-fpm +# starts. (Set PHP_FPM_AUTOTUNE_PROFILE, or the --php-fpm-profile flag.) +# +# 2. Runtime: the fpm_tune block below embeds cboxdk/fpm-tune, which measures +# live per-worker memory (PSS), and when the right size has moved it rewrites +# a pool drop-in and reloads php-fpm with SIGUSR2 (never a restart). Every +# change is validated against a throwaway copy, written atomically, and +# rolled back if the master does not come back. +# +# The boot value is the seed; the runtime loop refines it as traffic arrives. + +version: "1.0" + +global: + shutdown_timeout: 30 + log_format: json + log_level: info + + fpm_tune: + enabled: true + # apply: write drop-ins and reload php-fpm (the default). + # advisory: observe and only write a recommendation (set recommend_path). + mode: apply + interval: 30s + # Expose fpm_tune_* metrics (worker RSS/PSS, recommended vs configured + # workers, whether the machine is full). Empty disables the endpoint. + metrics_addr: ":9110" + # Leave the paths unset to let fpm-tune resolve them the way the standalone + # tool does: drop_in_dir = the directory the master includes, + # state_path = /var/lib/fpm-tune/state.json, backup_dir = its default. + # reserve_fraction: 0.2 # hold 20% of the budget back from the pools + # workload: web # default class for pools that spawn no children + +processes: + php-fpm: + enabled: true + command: ["php-fpm", "-F", "-R"] + restart: always diff --git a/docs/configuration/php-fpm-autotune.md b/docs/configuration/php-fpm-autotune.md index 644b115..fbbabed 100644 --- a/docs/configuration/php-fpm-autotune.md +++ b/docs/configuration/php-fpm-autotune.md @@ -11,6 +11,7 @@ Cbox Init includes intelligent PHP-FPM worker auto-tuning based on container res ## Table of Contents - [Overview](#overview) +- [Runtime Auto-Tuning](#runtime-auto-tuning) - [How It Works](#how-it-works) - [Application Profiles](#application-profiles) - [Safety Features](#safety-features) @@ -30,6 +31,41 @@ PHP-FPM worker configuration is critical for Laravel application performance and - Reserves memory for Nginx, Redis clients, system overhead - Validates calculations to prevent over-provisioning +## Runtime Auto-Tuning + +Everything above is **boot-time** sizing: a one-shot calculation that runs before +php-fpm starts and templates `pm.max_children` into the pool config from the +container's memory limit and a workload profile. It is a good starting value, but +it is a guess made before a single request has been served, from an assumed +per-worker memory figure. + +Cbox Init can also refine that value at **runtime**. The `global.fpm_tune` block +embeds the [fpm-tune](https://github.com/cboxdk/fpm-tune) engine as a built-in +background loop: it measures live per-worker memory (PSS, which does not +double-count shared OPcache), and when the right size has moved it rewrites a pool +drop-in and reloads php-fpm with `SIGUSR2` (a graceful reload, not a restart). Each +change is validated against a throwaway copy, written atomically, and rolled back +if the master does not come back. + +The two compose. The boot calculator seeds `pm.max_children` so php-fpm starts +sane; the runtime loop owns the number from there, following real memory use and +damping reloads with hysteresis so it does not react to every small drift. Enable +it alongside a php-fpm process: + +```yaml +global: + fpm_tune: + enabled: true + mode: apply # write drop-ins and reload; use "advisory" to only recommend + interval: 30s + metrics_addr: ":9110" # exposes fpm_tune_* metrics; empty disables it +``` + +A full example lives in `configs/examples/php-fpm-autotune.yaml`. The embedded +engine is the same tool that runs standalone, so do not point a separate `fpm-tune` +daemon at the same pools: the loop takes a lock on its state file, and a second +copy on the same state refuses to start. + ## How It Works ### Detection Phase diff --git a/go.mod b/go.mod index dff64c3..bc55d3b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ go 1.26.0 toolchain go1.26.6 require ( + github.com/cboxdk/fpm-tune v0.1.0-beta.25 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -32,6 +33,8 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cboxdk/fcgx v1.2.0 // indirect + github.com/cboxdk/phpfpm v1.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect @@ -63,6 +66,8 @@ require ( github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index 94356fc..ea953d9 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,12 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cboxdk/fcgx v1.2.0 h1:qBx9C0dwi4Ew4UWLSqfeUHVQDvCPjwb4u9q9eCSUJLE= +github.com/cboxdk/fcgx v1.2.0/go.mod h1:xOf5NLMyDhBEEdqBAT0LZY5ImTZp+NVeQdxMt/3waso= +github.com/cboxdk/fpm-tune v0.1.0-beta.25 h1:T70AaCIfaut/uXbnYQ2jrVXKxlqYnttcRPTb5x6rrCc= +github.com/cboxdk/fpm-tune v0.1.0-beta.25/go.mod h1:ldbnuEPHFJiI4DUmjU/C0t8TTaQFvZoFFzaAubkOcUg= +github.com/cboxdk/phpfpm v1.2.0 h1:w5dRTKotsWCzCeVomJhQ8HUFeDSxW4YtjMT25ic8o9s= +github.com/cboxdk/phpfpm v1.2.0/go.mod h1:Q1GYkBlqrfKv3QxQpqKtN6xlfIWkGze6YGIRxCImjZg= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -102,8 +108,14 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= diff --git a/internal/config/config.go b/internal/config/config.go index 7577713..434760d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "os" + "path/filepath" "sort" "strings" "time" @@ -110,6 +111,40 @@ func (c *Config) validateGlobal() error { if err := c.validateTracing(); err != nil { return err } + if err := c.validateFPMTune(); err != nil { + return err + } + return nil +} + +// validateFPMTune rejects a runtime-autotuner block the embedded loop cannot act +// on. Kept in step with fpm-tune's own serve.New, which refuses the same cases at +// startup โ€” catching them here means check-config and a reload report them +// instead of a daemon that starts and then does nothing useful. +func (c *Config) validateFPMTune() error { + ft := c.Global.FPMTune + if ft == nil || !ft.Enabled { + return nil + } + switch ft.Mode { + case "", "apply", "advisory": + default: + return fmt.Errorf("invalid fpm_tune.mode %q (valid: apply, advisory)", ft.Mode) + } + if ft.Interval < 0 { + return fmt.Errorf("fpm_tune.interval must not be negative") + } + if ft.ReserveFraction < 0 || ft.ReserveFraction >= 1 { + return fmt.Errorf("fpm_tune.reserve_fraction must be in [0, 1), got %v", ft.ReserveFraction) + } + // The one deterministic footgun fpm-tune itself refuses: a recommendation + // written where php-fpm would load it. The file carries fpm-tune's own marker, + // so the master would load it and the loop would refuse it every interval. + if ft.RecommendPath != "" && ft.DropInDir != "" && + filepath.Clean(filepath.Dir(ft.RecommendPath)) == filepath.Clean(ft.DropInDir) { + return fmt.Errorf("fpm_tune.recommend_path %s is inside drop_in_dir %s, where php-fpm "+ + "would load it; choose a path outside the pool directory", ft.RecommendPath, ft.DropInDir) + } return nil } diff --git a/internal/config/fpmtune_test.go b/internal/config/fpmtune_test.go new file mode 100644 index 0000000..9ca22ef --- /dev/null +++ b/internal/config/fpmtune_test.go @@ -0,0 +1,153 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// baseWithFPMTune returns a minimal valid config carrying the given autotuner +// block, defaults already applied, ready for Validate. +func baseWithFPMTune(ft *FPMTuneConfig) *Config { + c := &Config{ + Version: "1.0", + Global: GlobalConfig{FPMTune: ft}, + Processes: map[string]*Process{ + "php-fpm": {Enabled: true, Command: []string{"php-fpm", "-F"}}, + }, + } + c.SetDefaults() + + return c +} + +// TestFPMTuneRoundTripsUnderStrictDecode: config load uses KnownFields(true), so +// every fpm_tune key must be declared on the struct or a real config fails to +// load. This is the guard that the yaml tags match the field names. +func TestFPMTuneRoundTripsUnderStrictDecode(t *testing.T) { + body := ` +version: "1.0" +global: + fpm_tune: + enabled: true + mode: apply + interval: 15s + reserve_fraction: 0.2 + workload: web + drop_in_dir: /etc/php/8.4/fpm/pool.d + state_path: /var/lib/cbox-init/fpm-tune/state.json + backup_dir: /var/lib/cbox-init/fpm-tune/backup + metrics_addr: ":9110" + recommend_path: /var/lib/cbox-init/fpm-tune/recommended.conf +processes: + php-fpm: + enabled: true + command: ["php-fpm", "-F"] +` + dir := t.TempDir() + p := filepath.Join(dir, "c.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, err := LoadWithEnvExpansion(p) + if err != nil { + t.Fatalf("a valid fpm_tune block failed to load: %v", err) + } + ft := cfg.Global.FPMTune + if ft == nil { + t.Fatal("fpm_tune block was dropped on load") + } + if ft.Mode != "apply" || ft.Interval != 15*time.Second || ft.MetricsAddr != ":9110" { + t.Errorf("fpm_tune round-tripped wrong: %+v", ft) + } +} + +func TestFPMTuneDefaults(t *testing.T) { + // An enabled block with only the gate set gets the apply-mode defaults. + c := baseWithFPMTune(&FPMTuneConfig{Enabled: true}) + ft := c.Global.FPMTune + if ft.Mode != "apply" { + t.Errorf("mode default = %q, want apply", ft.Mode) + } + if ft.Interval != 30*time.Second { + t.Errorf("interval default = %v, want 30s", ft.Interval) + } + if ft.Workload != "web" { + t.Errorf("workload default = %q, want web", ft.Workload) + } + // Paths are left empty on purpose, so fpm-tune's own Defaults() resolve them. + if ft.StatePath != "" || ft.BackupDir != "" || ft.DropInDir != "" { + t.Errorf("paths should be left empty for fpm-tune to resolve, got %+v", ft) + } + + // No block stays no block. + if got := baseWithFPMTune(nil).Global.FPMTune; got != nil { + t.Errorf("a nil fpm_tune became %+v", got) + } +} + +func TestFPMTuneValidation(t *testing.T) { + cases := []struct { + name string + ft *FPMTuneConfig + wantErr bool + }{ + {"valid apply", &FPMTuneConfig{Enabled: true, Mode: "apply"}, false}, + {"valid advisory", &FPMTuneConfig{Enabled: true, Mode: "advisory"}, false}, + {"bad mode", &FPMTuneConfig{Enabled: true, Mode: "act"}, true}, + {"negative interval", &FPMTuneConfig{Enabled: true, Mode: "apply", Interval: -1}, true}, + {"reserve too high", &FPMTuneConfig{Enabled: true, Mode: "apply", ReserveFraction: 1.0}, true}, + {"reserve negative", &FPMTuneConfig{Enabled: true, Mode: "apply", ReserveFraction: -0.1}, true}, + { + "recommend inside drop-in", + &FPMTuneConfig{Enabled: true, Mode: "advisory", DropInDir: "/etc/php/pool.d", RecommendPath: "/etc/php/pool.d/rec.conf"}, + true, + }, + // A disabled block is never acted on, so even a nonsense one must not block a load. + {"disabled with bad mode is a no-op", &FPMTuneConfig{Enabled: false, Mode: "act"}, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := baseWithFPMTune(tc.ft).Validate() + if tc.wantErr && err == nil { + t.Error("expected a validation error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected validation error: %v", err) + } + }) + } +} + +// TestFPMTuneComprehensiveValidation: check-config uses ValidateComprehensive, a +// separate path from the fail-fast Validate, so the block must be caught there too +// โ€” with a suggestion that apply mode writes and reloads. +func TestFPMTuneComprehensiveValidation(t *testing.T) { + // Bad mode is a hard error on the comprehensive path. + c := baseWithFPMTune(&FPMTuneConfig{Enabled: true, Mode: "act", MetricsAddr: ":9110"}) + res, err := c.ValidateComprehensive() + if err == nil || !res.HasErrors() { + t.Fatal("a bad fpm_tune.mode was not reported by ValidateComprehensive") + } + + // A valid apply block passes but is flagged as writing/reloading. + c = baseWithFPMTune(&FPMTuneConfig{Enabled: true, Mode: "apply", MetricsAddr: ":9110"}) + res, err = c.ValidateComprehensive() + if err != nil { + t.Fatalf("a valid apply block failed comprehensive validation: %v", err) + } + found := false + for _, s := range res.Suggestions { + if strings.Contains(s.Field, "fpm_tune") { + found = true + break + } + } + if !found { + t.Error("apply mode did not produce a suggestion about writing/reloading php-fpm") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index de73857..eaec919 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -61,6 +61,7 @@ type GlobalConfig struct { OneshotHistoryMaxEntries int `yaml:"oneshot_history_max_entries" json:"oneshot_history_max_entries"` // Max oneshot history entries per process (default: 5000) OneshotHistoryMaxAge time.Duration `yaml:"oneshot_history_max_age" json:"oneshot_history_max_age"` // Max age of oneshot history entries (default: 24h) Readiness *ReadinessConfig `yaml:"readiness" json:"readiness"` // Container readiness file config for K8s + FPMTune *FPMTuneConfig `yaml:"fpm_tune" json:"fpm_tune"` // Built-in runtime PHP-FPM autotuner (embeds cboxdk/fpm-tune) HealthCheckStrict bool `yaml:"health_check_strict" json:"health_check_strict"` // Fail process startup if health monitor creation fails (default: false) DependencyTimeout time.Duration `yaml:"dependency_timeout" json:"dependency_timeout"` // Max time to wait for dependencies to become ready (default: 5m) ProcessStartTimeout time.Duration `yaml:"process_start_timeout" json:"process_start_timeout"` // Timeout for starting a single process (default: 30s) @@ -304,6 +305,32 @@ type ReadinessConfig struct { HTTPHost string `yaml:"http_host" json:"http_host"` // Bind host for the HTTP endpoint (default: 0.0.0.0 so kubelet can reach it) } +// FPMTuneConfig configures the built-in runtime PHP-FPM autotuner. +// +// The boot-time calculator (the php-fpm autotune profile) still sizes +// pm.max_children once, before php-fpm starts, because a loop cannot size a +// master that is not running yet. This loop takes over at runtime: it measures +// live per-worker memory (PSS), and when the right size has moved it rewrites a +// pool drop-in and reloads php-fpm with SIGUSR2, never a restart. Change is +// validated against a throwaway copy, written atomically, and rolled back if the +// master does not come back. +// +// It embeds github.com/cboxdk/fpm-tune, which is also a standalone tool. Do not +// run both against the same pools: the daemon takes a lock on its state file, so +// a second copy pointed at the same state refuses to start. +type FPMTuneConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` // Enable the runtime autotuner + Mode string `yaml:"mode" json:"mode"` // "apply" (write + reload) | "advisory" (observe + recommend only); default apply + Interval time.Duration `yaml:"interval" json:"interval"` // How often pools are sampled (default 30s) + ReserveFraction float64 `yaml:"reserve_fraction" json:"reserve_fraction"` // Fraction of the budget held back from the pools (0 = fpm-tune's default) + Workload string `yaml:"workload" json:"workload"` // Default workload class for pools that declare none (default "web") + DropInDir string `yaml:"drop_in_dir" json:"drop_in_dir"` // Where pool drop-ins are written; empty = the directory the master includes + StatePath string `yaml:"state_path" json:"state_path"` // Where learned baselines persist (empty = fpm-tune's default) + BackupDir string `yaml:"backup_dir" json:"backup_dir"` // Rollback / self-repair directory (empty = fpm-tune's default) + MetricsAddr string `yaml:"metrics_addr" json:"metrics_addr"` // Address for fpm-tune's own /metrics, e.g. ":9110" (empty disables it) + RecommendPath string `yaml:"recommend_path" json:"recommend_path"` // Advisory mode: write the plan here for copying by hand (empty disables it) +} + // setGlobalDefaults sets default values for global configuration func (c *Config) setGlobalDefaults() { c.setGlobalBasicDefaults() @@ -312,6 +339,26 @@ func (c *Config) setGlobalDefaults() { c.setGlobalACLDefaults() c.setGlobalTracingDefaults() c.setGlobalHistoryDefaults() + c.setGlobalFPMTuneDefaults() +} + +// setGlobalFPMTuneDefaults fills the runtime autotuner's defaults, leaving the +// paths (state, backup, drop-in) empty so fpm-tune's own Defaults() resolve them +// the same way the standalone tool does. +func (c *Config) setGlobalFPMTuneDefaults() { + ft := c.Global.FPMTune + if ft == nil { + return + } + if ft.Mode == "" { + ft.Mode = "apply" + } + if ft.Interval == 0 { + ft.Interval = 30 * time.Second + } + if ft.Workload == "" { + ft.Workload = "web" + } } // setGlobalBasicDefaults sets basic global defaults diff --git a/internal/config/validation.go b/internal/config/validation.go index be4d028..f4c9950 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "path/filepath" "runtime" "slices" "strings" @@ -186,6 +187,43 @@ func (c *Config) validateGlobalSettings(result *ValidationResult) { c.validateGlobalAPISettings(result) c.validateGlobalMetricsSettings(result) c.validateGlobalReadinessSettings(result) + c.validateGlobalFPMTuneSettings(result) +} + +// validateGlobalFPMTuneSettings checks the built-in runtime autotuner block, and +// nudges toward an observable, correct setup. Errors mirror the fail-fast +// validateFPMTune; the warnings and suggestions are check-config's job. +func (c *Config) validateGlobalFPMTuneSettings(result *ValidationResult) { + ft := c.Global.FPMTune + if ft == nil || !ft.Enabled { + return + } + + validModes := []string{"apply", "advisory"} + if ft.Mode != "" && !slices.Contains(validModes, ft.Mode) { + result.AddError("global.fpm_tune.mode", fmt.Sprintf("Invalid mode: %s", ft.Mode), fmt.Sprintf("Must be one of: %s", strings.Join(validModes, ", "))) + } + if ft.Interval < 0 { + result.AddError("global.fpm_tune.interval", "Must not be negative", "Use a value like 30s, or leave unset for the 30s default") + } + if ft.ReserveFraction < 0 || ft.ReserveFraction >= 1 { + result.AddError("global.fpm_tune.reserve_fraction", fmt.Sprintf("Out of range (%v)", ft.ReserveFraction), "Must be in [0, 1); leave unset for fpm-tune's default") + } + if ft.RecommendPath != "" && ft.DropInDir != "" && + filepath.Clean(filepath.Dir(ft.RecommendPath)) == filepath.Clean(ft.DropInDir) { + result.AddError("global.fpm_tune.recommend_path", "Inside drop_in_dir, where php-fpm would load it", "Choose a path outside the pool directory, such as /var/lib/fpm-tune/recommended.conf") + } + + // apply is the point of embedding it, but it writes production config and + // reloads php-fpm โ€” worth stating plainly in a config review. + if ft.Mode == "" || ft.Mode == "apply" { + result.AddSuggestion("global.fpm_tune.mode", "Apply mode writes pool drop-ins and reloads php-fpm (SIGUSR2)", "Use mode: advisory to observe and only write a recommendation") + } + // Observability: without a metrics address (and, in advisory mode, without a + // recommend path) the loop's decisions are visible only in the log. + if ft.MetricsAddr == "" { + result.AddSuggestion("global.fpm_tune.metrics_addr", "No metrics endpoint for the autotuner", "Set metrics_addr like \":9110\" to expose fpm_tune_* metrics") + } } // validateGlobalBasicSettings validates shutdown timeout, logging, and restart settings diff --git a/sbom.json b/sbom.json index 4b46105..4861aba 100644 --- a/sbom.json +++ b/sbom.json @@ -56,6 +56,87 @@ "type": "library", "version": "v1.0.1" }, + { + "bom-ref": "pkg:golang/github.com/cboxdk/fcgx@v1.2.0?type=module", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/cboxdk/fcgx" + } + ], + "hashes": [ + { + "alg": "SHA-256", + "content": "a81c7d0b47708b8130e1458b4aa7de5075500ef08f8f06f8bbdabd78249424b1" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "github.com/cboxdk/fcgx", + "purl": "pkg:golang/github.com/cboxdk/fcgx@v1.2.0?type=module", + "scope": "required", + "type": "library", + "version": "v1.2.0" + }, + { + "bom-ref": "pkg:golang/github.com/cboxdk/fpm-tune@v0.1.0-beta.25?type=module", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/cboxdk/fpm-tune" + } + ], + "hashes": [ + { + "alg": "SHA-256", + "content": "4fbd0068221f6aeb7fb976e7610da3ad55cac65a989edb5c44f4dbe71eabac27" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "github.com/cboxdk/fpm-tune", + "purl": "pkg:golang/github.com/cboxdk/fpm-tune@v0.1.0-beta.25?type=module", + "scope": "required", + "type": "library", + "version": "v0.1.0-beta.25" + }, + { + "bom-ref": "pkg:golang/github.com/cboxdk/phpfpm@v1.2.0?type=module", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/cboxdk/phpfpm" + } + ], + "hashes": [ + { + "alg": "SHA-256", + "content": "c397514caa2db160b309e568989850f075057834b15b862d8cc4f6e6273ca3db" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "github.com/cboxdk/phpfpm", + "purl": "pkg:golang/github.com/cboxdk/phpfpm@v1.2.0?type=module", + "scope": "required", + "type": "library", + "version": "v1.2.0" + }, { "bom-ref": "pkg:golang/github.com/cenkalti/backoff/v5@v5.0.3?type=module", "externalReferences": [ @@ -1055,6 +1136,33 @@ "type": "library", "version": "v3.0.1" }, + { + "bom-ref": "pkg:golang/github.com/shirou/gopsutil/v3@v3.24.5?type=module", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/shirou/gopsutil" + } + ], + "hashes": [ + { + "alg": "SHA-256", + "content": "8b4b7c90bfa4413be90184e87ae8959374e00de28e162a193b766dcff899f692" + } + ], + "licenses": [ + { + "license": { + "id": "BSD-3-Clause" + } + } + ], + "name": "github.com/shirou/gopsutil/v3", + "purl": "pkg:golang/github.com/shirou/gopsutil/v3@v3.24.5?type=module", + "scope": "required", + "type": "library", + "version": "v3.24.5" + }, { "bom-ref": "pkg:golang/github.com/shirou/gopsutil/v4@v4.26.7?type=module", "externalReferences": [ @@ -1082,6 +1190,33 @@ "type": "library", "version": "v4.26.7" }, + { + "bom-ref": "pkg:golang/github.com/shoenig/go-m1cpu@v0.1.6?type=module", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/shoenig/go-m1cpu" + } + ], + "hashes": [ + { + "alg": "SHA-256", + "content": "9f174a40d70407abf3800d84d9bbf3288611b8d8fb5cd2784bf6914b02b316d3" + } + ], + "licenses": [ + { + "license": { + "id": "MPL-2.0" + } + } + ], + "name": "github.com/shoenig/go-m1cpu", + "purl": "pkg:golang/github.com/shoenig/go-m1cpu@v0.1.6?type=module", + "scope": "required", + "type": "library", + "version": "v0.1.6" + }, { "bom-ref": "pkg:golang/github.com/spf13/cobra@v1.10.2?type=module", "externalReferences": [ @@ -1611,6 +1746,7 @@ "dependencies": [ { "dependsOn": [ + "pkg:golang/github.com/cboxdk/fpm-tune@v0.1.0-beta.25?type=module", "pkg:golang/github.com/charmbracelet/bubbles@v1.0.0?type=module", "pkg:golang/github.com/charmbracelet/bubbletea@v1.3.10?type=module", "pkg:golang/github.com/charmbracelet/lipgloss@v1.1.0?type=module", @@ -1637,6 +1773,67 @@ { "ref": "pkg:golang/github.com/beorn7/perks@v1.0.1?type=module" }, + { + "ref": "pkg:golang/github.com/cboxdk/fcgx@v1.2.0?type=module" + }, + { + "dependsOn": [ + "pkg:golang/github.com/aymanbagabas/go-osc52/v2@v2.0.1?type=module", + "pkg:golang/github.com/beorn7/perks@v1.0.1?type=module", + "pkg:golang/github.com/cboxdk/fcgx@v1.2.0?type=module", + "pkg:golang/github.com/cboxdk/phpfpm@v1.2.0?type=module", + "pkg:golang/github.com/cespare/xxhash/v2@v2.3.0?type=module", + "pkg:golang/github.com/charmbracelet/bubbles@v1.0.0?type=module", + "pkg:golang/github.com/charmbracelet/bubbletea@v1.3.10?type=module", + "pkg:golang/github.com/charmbracelet/colorprofile@v0.4.1?type=module", + "pkg:golang/github.com/charmbracelet/lipgloss@v1.1.0?type=module", + "pkg:golang/github.com/charmbracelet/x/ansi@v0.11.6?type=module", + "pkg:golang/github.com/charmbracelet/x/cellbuf@v0.0.15?type=module", + "pkg:golang/github.com/charmbracelet/x/term@v0.2.2?type=module", + "pkg:golang/github.com/erikgeiser/coninput@v0.0.0-20211004153227-1c3628e74d0f?type=module", + "pkg:golang/github.com/go-ole/go-ole@v1.2.6?type=module", + "pkg:golang/github.com/lucasb-eyer/go-colorful@v1.3.0?type=module", + "pkg:golang/github.com/lufia/plan9stats@v0.0.0-20211012122336-39d0f177ccd0?type=module", + "pkg:golang/github.com/mattn/go-isatty@v0.0.20?type=module", + "pkg:golang/github.com/mattn/go-localereader@v0.0.1?type=module", + "pkg:golang/github.com/mattn/go-runewidth@v0.0.19?type=module", + "pkg:golang/github.com/muesli/ansi@v0.0.0-20230316100256-276c6243b2f6?type=module", + "pkg:golang/github.com/muesli/cancelreader@v0.2.2?type=module", + "pkg:golang/github.com/muesli/termenv@v0.16.0?type=module", + "pkg:golang/github.com/munnerz/goautoneg@v0.0.0-20191010083416-a7dc8b61c822?type=module", + "pkg:golang/github.com/power-devops/perfstat@v0.0.0-20240221224432-82ca36839d55?type=module", + "pkg:golang/github.com/prometheus/client_golang@v1.24.1?type=module", + "pkg:golang/github.com/prometheus/client_model@v0.6.2?type=module", + "pkg:golang/github.com/prometheus/common@v0.70.1?type=module", + "pkg:golang/github.com/prometheus/procfs@v0.21.1?type=module", + "pkg:golang/github.com/rivo/uniseg@v0.4.7?type=module", + "pkg:golang/github.com/shirou/gopsutil/v3@v3.24.5?type=module", + "pkg:golang/github.com/shoenig/go-m1cpu@v0.1.6?type=module", + "pkg:golang/github.com/tklauser/go-sysconf@v0.3.16?type=module", + "pkg:golang/github.com/tklauser/numcpus@v0.11.0?type=module", + "pkg:golang/github.com/xo/terminfo@v0.0.0-20220910002029-abceb7e1c41e?type=module", + "pkg:golang/github.com/yusufpapurcu/wmi@v1.2.4?type=module", + "pkg:golang/golang.org/x/sys@v0.47.0?type=module", + "pkg:golang/golang.org/x/text@v0.40.0?type=module", + "pkg:golang/google.golang.org/protobuf@v1.36.11?type=module" + ], + "ref": "pkg:golang/github.com/cboxdk/fpm-tune@v0.1.0-beta.25?type=module" + }, + { + "dependsOn": [ + "pkg:golang/github.com/cboxdk/fcgx@v1.2.0?type=module", + "pkg:golang/github.com/go-ole/go-ole@v1.2.6?type=module", + "pkg:golang/github.com/lufia/plan9stats@v0.0.0-20211012122336-39d0f177ccd0?type=module", + "pkg:golang/github.com/power-devops/perfstat@v0.0.0-20240221224432-82ca36839d55?type=module", + "pkg:golang/github.com/shirou/gopsutil/v3@v3.24.5?type=module", + "pkg:golang/github.com/shoenig/go-m1cpu@v0.1.6?type=module", + "pkg:golang/github.com/tklauser/go-sysconf@v0.3.16?type=module", + "pkg:golang/github.com/tklauser/numcpus@v0.11.0?type=module", + "pkg:golang/github.com/yusufpapurcu/wmi@v1.2.4?type=module", + "pkg:golang/golang.org/x/sys@v0.47.0?type=module" + ], + "ref": "pkg:golang/github.com/cboxdk/phpfpm@v1.2.0?type=module" + }, { "ref": "pkg:golang/github.com/cenkalti/backoff/v5@v5.0.3?type=module" }, @@ -1924,6 +2121,20 @@ { "ref": "pkg:golang/github.com/robfig/cron/v3@v3.0.1?type=module" }, + { + "dependsOn": [ + "pkg:golang/github.com/go-ole/go-ole@v1.2.6?type=module", + "pkg:golang/github.com/lufia/plan9stats@v0.0.0-20211012122336-39d0f177ccd0?type=module", + "pkg:golang/github.com/power-devops/perfstat@v0.0.0-20240221224432-82ca36839d55?type=module", + "pkg:golang/github.com/shoenig/go-m1cpu@v0.1.6?type=module", + "pkg:golang/github.com/tklauser/go-sysconf@v0.3.16?type=module", + "pkg:golang/github.com/tklauser/numcpus@v0.11.0?type=module", + "pkg:golang/github.com/yusufpapurcu/wmi@v1.2.4?type=module", + "pkg:golang/golang.org/x/sys@v0.47.0?type=module", + "pkg:golang/gopkg.in/yaml.v3@v3.0.1?type=module" + ], + "ref": "pkg:golang/github.com/shirou/gopsutil/v3@v3.24.5?type=module" + }, { "dependsOn": [ "pkg:golang/github.com/ebitengine/purego@v0.10.2?type=module", @@ -1938,6 +2149,9 @@ ], "ref": "pkg:golang/github.com/shirou/gopsutil/v4@v4.26.7?type=module" }, + { + "ref": "pkg:golang/github.com/shoenig/go-m1cpu@v0.1.6?type=module" + }, { "dependsOn": [ "pkg:golang/github.com/inconshreveable/mousetrap@v1.1.0?type=module", diff --git a/tests/integration/Dockerfile.fpmtune b/tests/integration/Dockerfile.fpmtune new file mode 100644 index 0000000..a0b284d --- /dev/null +++ b/tests/integration/Dockerfile.fpmtune @@ -0,0 +1,20 @@ +# Runtime PHP-FPM autotuner end-to-end image. cbox-init is PID 1, supervising a +# real php-fpm and running the embedded fpm-tune loop. libfcgi-bin (cgi-fcgi) and +# busy.php are baked in so the driver script can generate load with no network. +FROM php:8.4-fpm + +ARG TARGETARCH=amd64 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libfcgi-bin \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/lib/fpm-tune/backup /etc/cbox-init \ + && printf '%s' ' /var/www/html/busy.php + +# The linux binaries come from `make build-all` (local) or the CI `binaries` +# artifact; TARGETARCH selects the one matching the build platform. +COPY build/cbox-init-linux-${TARGETARCH} /usr/local/bin/cbox-init +COPY tests/integration/fpm-tune-config.yaml /etc/cbox-init/cbox-init.yaml +RUN chmod +x /usr/local/bin/cbox-init + +ENTRYPOINT ["/usr/local/bin/cbox-init"] diff --git a/tests/integration/e2e-fpm-tune.sh b/tests/integration/e2e-fpm-tune.sh new file mode 100755 index 0000000..e976af2 --- /dev/null +++ b/tests/integration/e2e-fpm-tune.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# End-to-end test for the embedded runtime PHP-FPM autotuner (global.fpm_tune). +# +# Runs cbox-init as PID 1 in a php:8.4-fpm container, supervising a real php-fpm +# with the fpm-tune loop in apply mode, and asserts the full chain: +# +# discover www -> enable its status page -> scrape (PSS) -> size -> +# apply a resize under load by reloading php-fpm with SIGUSR2 (never a restart). +# +# The driver is external (docker exec / curl from the host) because php-fpm is a +# longrun process, so the oneshot-verify pattern (Dockerfile.pid1) does not apply. +# Exits non-zero on the first failed assertion, which fails the CI step. +set -euo pipefail + +IMAGE=cbox-init-fpmtune-e2e +NAME=cbox-init-fpmtune-e2e +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +fail() { + echo "โœ— FAILED: $1" + echo "---- container logs (tail) ----" + docker logs "$NAME" 2>&1 | tail -40 || true + exit 1 +} + +# make build-all (local) or the CI artifact must have produced the linux binary +# the Dockerfile COPYs for this build platform. +[ -f "$ROOT/build/cbox-init-linux-amd64" ] || [ -f "$ROOT/build/cbox-init-linux-arm64" ] \ + || fail "build/cbox-init-linux-* missing; run 'make build-all' first" + +echo "=== build image ===" +docker build -f "$ROOT/tests/integration/Dockerfile.fpmtune" -t "$IMAGE" "$ROOT" >/dev/null + +echo "=== run cbox-init as PID 1 (memory-bounded for a predictable budget) ===" +cleanup +docker run -d --name "$NAME" --memory=512m -p 9110:9110 "$IMAGE" >/dev/null + +# One www-pool metric value, or empty until the loop has scraped it once. +metric() { curl -sf --max-time 5 http://localhost:9110/metrics 2>/dev/null | awk -v k="$1" '$1==k{print $2}'; } + +# The php-fpm master pid, found via /proc without pgrep/ps (minimal image). +master_pid() { + docker exec "$NAME" sh -c ' + for p in /proc/[0-9]*/; do + c=$(tr "\0" " " < "$p/cmdline" 2>/dev/null) || continue + case "$c" in *"master process"*) echo "$p" | tr -dc "0-9"; return;; esac + done' +} + +echo "=== the loop discovers www and serves metrics ===" +ready="" +for _ in $(seq 1 40); do + [ -n "$(metric 'fpm_tune_pool_workers_configured{pool="www"}')" ] && { ready=1; break; } + sleep 1 +done +[ -n "$ready" ] || fail "the loop never discovered the www pool on /metrics" +echo " โœ“ www discovered; fpm_tune_* metrics served" + +echo "=== apply enabled the status page (drop-in written) ===" +docker exec "$NAME" test -f /usr/local/etc/php-fpm.d/zz-fpm-tune-status.conf \ + || fail "status drop-in zz-fpm-tune-status.conf was not written" +echo " โœ“ zz-fpm-tune-status.conf present" + +configured0=$(metric 'fpm_tune_pool_workers_configured{pool="www"}') +master0=$(master_pid) +[ -n "$master0" ] || fail "could not find the php-fpm master pid" +echo " โœ“ baseline: configured=$configured0, php-fpm master pid=$master0" + +echo "=== drive saturating load; the loop must resize the pool ===" +docker exec -d "$NAME" sh -c ' + end=$(( $(date +%s) + 150 )) + while [ $(date +%s) -lt $end ]; do + i=0 + while [ $i -lt 25 ]; do + ( SCRIPT_FILENAME=/var/www/html/busy.php SCRIPT_NAME=/busy.php \ + REQUEST_METHOD=GET QUERY_STRING= \ + cgi-fcgi -bind -connect 127.0.0.1:9000 >/dev/null 2>&1 ) & + i=$((i + 1)) + done + wait + done' + +resized="" +for _ in $(seq 1 150); do + c=$(metric 'fpm_tune_pool_workers_configured{pool="www"}') + r=$(metric 'fpm_tune_pool_workers_recommended{pool="www"}') + if [ -n "$c" ] && [ "$c" -gt "$configured0" ]; then + resized=1 + echo " โœ“ resized under load: configured $configured0 -> $c (recommended $r)" + break + fi + sleep 1 +done +[ -n "$resized" ] || fail "no resize under load (configured stayed at $configured0)" + +echo "=== the resize wrote a pm.max_children drop-in ===" +docker exec "$NAME" sh -c 'grep -q "pm.max_children" /usr/local/etc/php-fpm.d/zz-fpm-tune.conf' \ + || fail "pm drop-in zz-fpm-tune.conf with pm.max_children was not written" +echo " โœ“ zz-fpm-tune.conf carries pm.max_children" + +echo "=== the reload was SIGUSR2, not a restart (master pid unchanged) ===" +master1=$(master_pid) +[ -n "$master1" ] && [ "$master0" = "$master1" ] \ + || fail "php-fpm master pid changed ($master0 -> ${master1:-gone}): a restart, not a graceful reload" +echo " โœ“ master pid $master1 unchanged across the resize" + +echo "=== graceful shutdown stops the loop before php-fpm drains ===" +docker stop -t 15 "$NAME" >/dev/null +code=$(docker inspect -f '{{.State.ExitCode}}' "$NAME") +[ "$code" = "0" ] || fail "graceful shutdown exited $code, expected 0" +echo " โœ“ graceful shutdown exit 0" + +echo "=== E2E PASSED: init embeds and drives fpm-tune end-to-end ===" diff --git a/tests/integration/fpm-tune-config.yaml b/tests/integration/fpm-tune-config.yaml new file mode 100644 index 0000000..4fbb36e --- /dev/null +++ b/tests/integration/fpm-tune-config.yaml @@ -0,0 +1,22 @@ +# Config for the runtime PHP-FPM autotuner e2e (tests/integration/e2e-fpm-tune.sh). +# cbox-init runs as PID 1, supervises php-fpm, and runs the embedded fpm-tune +# loop in apply mode. A short interval keeps the test quick; the driver script +# generates load and asserts the loop resizes and reloads php-fpm. +version: "1.0" +global: + log_level: info + log_format: text + shutdown_timeout: 10 + fpm_tune: + enabled: true + mode: apply + interval: 3s + drop_in_dir: /usr/local/etc/php-fpm.d + state_path: /var/lib/fpm-tune/state.json + backup_dir: /var/lib/fpm-tune/backup + metrics_addr: ":9110" +processes: + php-fpm: + enabled: true + command: ["php-fpm", "-F"] + restart: always diff --git a/tools/licensecheck/main.go b/tools/licensecheck/main.go index 48609de..8d74a4e 100644 --- a/tools/licensecheck/main.go +++ b/tools/licensecheck/main.go @@ -38,7 +38,15 @@ type exception struct { // Licenses accepted for one named component only, each with the reason it is // acceptable. Anything not listed here has to be permissive. -var exceptions = map[string]exception{} +var exceptions = map[string]exception{ + "github.com/shoenig/go-m1cpu": { + license: "MPL-2.0", + reason: "darwin/arm64-only (//go:build darwin && arm64 && cgo), pulled " + + "transitively through gopsutil for Apple Silicon CPU detection and never " + + "compiled into the Linux production binary. MPL-2.0 is file-level weak " + + "copyleft: linking it imposes nothing on our code, and we do not modify it.", + }, +} type license struct { License struct {