diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dfb1ec9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- **Permission setup: respect PUID/PGID and auto-detect www-data uid** — Framework directory ownership (`storage/`, `var/`, `wp-content/`) previously hardcoded uid/gid 82 (Alpine convention), which silently broke on Debian-based images where `www-data` is uid 33. The binary now resolves the app user via: (1) `PUID`/`PGID` env vars, (2) `/etc/passwd` lookup of `www-data`, (3) fallback to 82/82. This fixes Laravel 500 errors caused by view cache write failures on `php-fpm-nginx:*-bookworm` images. + +## [2.1.0] - 2026-05-07 + +### Added + +- CLI commands for process control (`list`, `status`, `start`, `stop`, `restart`, `scale`, `reload-config`, `logs`) +- Always-on Unix socket for CLI-to-daemon communication +- Log file tailing with rotation support +- API client package (`internal/apiclient`) extracted from TUI +- Log subscriber system for real-time log streaming + +## [2.0.1] - 2026-04-17 + +### Fixed + +- Oneshot processes now default to `restart: never` instead of inheriting the global restart policy + +## [2.0.0] - 2026-04-17 + +### Changed + +- Rebranded from phpeek-pm to cbox-init + +## [1.2.2] + +### Added + +- Scaffolding `--observability` flag and streamlined presets diff --git a/cmd/cbox-init/cmd_test.go b/cmd/cbox-init/cmd_test.go index d1e2267..c249521 100644 --- a/cmd/cbox-init/cmd_test.go +++ b/cmd/cbox-init/cmd_test.go @@ -1391,9 +1391,9 @@ func TestTUIRemoteFlag(t *testing.T) { return } - // Default should be localhost:9180 - if flag.DefValue != "http://localhost:9180" { - t.Errorf("expected default remote URL to be http://localhost:9180, got %s", flag.DefValue) + // Default should be empty (auto-discovers Unix socket) + if flag.DefValue != "" { + t.Errorf("expected default remote URL to be empty (auto-discover), got %s", flag.DefValue) } } diff --git a/cmd/cbox-init/list.go b/cmd/cbox-init/list.go index 4ad3ce8..c0b235e 100644 --- a/cmd/cbox-init/list.go +++ b/cmd/cbox-init/list.go @@ -78,7 +78,7 @@ func newClient(urlFlag string) *apiclient.Client { if urlFlag != "" { return apiclient.New(urlFlag, auth) } - return apiclient.New("http://localhost:9180", auth) + return apiclient.NewWithAutoDiscover("http://localhost:9180", auth) } // formatDuration formats a duration as human-readable diff --git a/cmd/cbox-init/logs.go b/cmd/cbox-init/logs.go index 26ede90..f381570 100644 --- a/cmd/cbox-init/logs.go +++ b/cmd/cbox-init/logs.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "strings" "github.com/cboxdk/init/internal/logger" "github.com/spf13/cobra" @@ -47,10 +48,14 @@ func runLogs(cmd *cobra.Command, args []string) { } client := newClient(logsURL) + levelFilter, err := parseLogLevelFilter(logsLevel) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid log level %q: %v\n", logsLevel, err) + os.Exit(1) + } // Fetch historical logs var logs []logger.LogEntry - var err error if processName != "" { logs, err = client.GetLogs(processName, logsTail) } else { @@ -63,7 +68,9 @@ func runLogs(cmd *cobra.Command, args []string) { // Print historical logs for _, entry := range logs { - printLogEntry(entry) + if shouldPrintLogEntry(entry, levelFilter) { + printLogEntry(entry) + } } // If not following, we're done @@ -82,7 +89,9 @@ func runLogs(cmd *cobra.Command, args []string) { } for entry := range ch { - printLogEntry(entry) + if shouldPrintLogEntry(entry, levelFilter) { + printLogEntry(entry) + } } } @@ -90,3 +99,33 @@ func printLogEntry(entry logger.LogEntry) { ts := entry.Timestamp.Format("15:04:05.000") fmt.Printf("%s [%s] %s: %s\n", ts, entry.Level, entry.ProcessName, entry.Message) } + +func parseLogLevelFilter(level string) (int, error) { + switch strings.ToLower(level) { + case "", "all": + return -1, nil + case "debug": + return 0, nil + case "info": + return 1, nil + case "warn", "warning": + return 2, nil + case "error": + return 3, nil + default: + return 0, fmt.Errorf("must be one of debug, info, warn, error, all") + } +} + +func shouldPrintLogEntry(entry logger.LogEntry, minLevel int) bool { + if minLevel < 0 { + return true + } + + entryLevel, err := parseLogLevelFilter(entry.Level) + if err != nil { + entryLevel = 1 + } + + return entryLevel >= minLevel +} diff --git a/cmd/cbox-init/logs_test.go b/cmd/cbox-init/logs_test.go new file mode 100644 index 0000000..fe050b8 --- /dev/null +++ b/cmd/cbox-init/logs_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "testing" + + "github.com/cboxdk/init/internal/logger" +) + +func TestParseLogLevelFilter(t *testing.T) { + tests := []struct { + level string + want int + wantErr bool + }{ + {level: "all", want: -1}, + {level: "debug", want: 0}, + {level: "info", want: 1}, + {level: "warn", want: 2}, + {level: "warning", want: 2}, + {level: "error", want: 3}, + {level: "TRACE", wantErr: true}, + } + + for _, tt := range tests { + got, err := parseLogLevelFilter(tt.level) + if (err != nil) != tt.wantErr { + t.Fatalf("parseLogLevelFilter(%q) error = %v, wantErr %v", tt.level, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Fatalf("parseLogLevelFilter(%q) = %d, want %d", tt.level, got, tt.want) + } + } +} + +func TestShouldPrintLogEntry(t *testing.T) { + tests := []struct { + name string + entry logger.LogEntry + minLevel int + want bool + }{ + { + name: "all passes", + entry: logger.LogEntry{Level: "debug"}, + minLevel: -1, + want: true, + }, + { + name: "warn hides info", + entry: logger.LogEntry{Level: "info"}, + minLevel: 2, + want: false, + }, + { + name: "warn shows error", + entry: logger.LogEntry{Level: "error"}, + minLevel: 2, + want: true, + }, + { + name: "unknown level falls back to info", + entry: logger.LogEntry{Level: "custom"}, + minLevel: 1, + want: true, + }, + } + + for _, tt := range tests { + if got := shouldPrintLogEntry(tt.entry, tt.minLevel); got != tt.want { + t.Fatalf("%s: shouldPrintLogEntry() = %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/cmd/cbox-init/tui.go b/cmd/cbox-init/tui.go index d9e59f8..ca71176 100644 --- a/cmd/cbox-init/tui.go +++ b/cmd/cbox-init/tui.go @@ -36,7 +36,7 @@ var ( ) func init() { - tuiCmd.Flags().StringVar(&tuiRemote, "remote", "http://localhost:9180", "API endpoint to connect to") + tuiCmd.Flags().StringVar(&tuiRemote, "remote", "", "API endpoint to connect to (auto-discovers Unix socket by default)") } func runTUI(cmd *cobra.Command, args []string) { @@ -45,7 +45,11 @@ func runTUI(cmd *cobra.Command, args []string) { } func runTUIRemote(apiURL string) { - fmt.Fprintf(os.Stderr, "🔗 Connecting to remote API: %s\n", apiURL) + if apiURL == "" { + fmt.Fprintln(os.Stderr, "🔗 Connecting to local API (auto-discovering Unix socket)") + } else { + fmt.Fprintf(os.Stderr, "🔗 Connecting to remote API: %s\n", apiURL) + } // Get auth token if set auth := os.Getenv("CBOX_INIT_API_AUTH") diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 0bcec1a..b29ed67 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -77,6 +77,47 @@ PHP_FPM_AUTOTUNE_PROFILE=heavy See [PHP-FPM Auto-Tuning](../php-fpm-autotune) for complete guide. +## Permission / Ownership + +These environment variables control which uid/gid cbox-init uses when chowning framework directories (Laravel `storage/`, Symfony `var/`, WordPress `wp-content/`). + +**Note:** These are standalone variables — they do **not** follow the `CBOX_INIT_` prefix convention because they are a widely adopted container convention (used by linuxserver.io images, s6-overlay, etc.). + +| Variable | Description | Default behaviour | +|----------|-------------|-------------------| +| `PUID` | User ID for framework directory ownership | Auto-detected from `/etc/passwd` | +| `PGID` | Group ID for framework directory ownership | Auto-detected from `/etc/passwd` | + +### Resolution order + +cbox-init resolves the app user in this order: + +1. **`PUID` + `PGID` environment variables** — explicit operator override. Both must be set to valid non-negative integers; if either is missing or invalid, the override is skipped entirely. +2. **`/etc/passwd` lookup of `www-data`** — works on both Debian (uid 33) and Alpine (uid 82) without hardcoding either. +3. **Fallback to uid 82 / gid 82** (Alpine convention) — only used when the lookup also fails. + +The resolved source is logged at startup so you can verify which path was taken: + +``` +INFO App user from PUID/PGID env uid=33 gid=33 +# or +INFO App user from /etc/passwd lookup user=www-data uid=33 gid=33 +``` + +### Examples + +```bash +# Explicit override (e.g., match your host user for bind-mount permissions) +docker run -e PUID=1000 -e PGID=1000 myapp + +# Kubernetes — set via env in the pod spec +env: + - name: PUID + value: "33" + - name: PGID + value: "33" +``` + ## Global Settings Reference ### Shutdown Configuration diff --git a/docs/getting-started/docker-integration.md b/docs/getting-started/docker-integration.md index d142195..52ba843 100644 --- a/docs/getting-started/docker-integration.md +++ b/docs/getting-started/docker-integration.md @@ -126,8 +126,10 @@ RUN php artisan config:cache && \ COPY docker/cbox-init.yaml /etc/cbox-init/cbox-init.yaml COPY docker/nginx.conf /etc/nginx/nginx.conf -# Set permissions -RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache +# Cbox Init automatically chowns framework directories (storage/, bootstrap/cache) +# at startup using the www-data user from /etc/passwd. +# Override with PUID/PGID if needed (e.g., -e PUID=1000 -e PGID=1000). +# See: docs/configuration/environment-variables.md ENTRYPOINT ["/usr/local/bin/cbox-init"] ``` diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go index 5f61449..2b18a2a 100644 --- a/internal/apiclient/client.go +++ b/internal/apiclient/client.go @@ -26,14 +26,30 @@ type Client struct { client *http.Client } -// New creates a new API client with auto-detection -// Tries Unix socket first, falls back to TCP +// BaseURL returns the API base URL this client is configured to use. +func (c *Client) BaseURL() string { + return c.baseURL +} + +// New creates a client for an explicit API endpoint. +// When baseURL is non-empty, no socket auto-discovery is attempted. func New(baseURL, auth string) *Client { client := &Client{ baseURL: baseURL, auth: auth, + client: &http.Client{ + Timeout: 10 * time.Second, + }, } + return client +} + +// NewWithAutoDiscover creates a client that prefers known local Unix sockets +// and falls back to the provided TCP baseURL when none are reachable. +func NewWithAutoDiscover(baseURL, auth string) *Client { + client := New(baseURL, auth) + // Auto-detect socket paths (priority order) socketPaths := []string{ "/var/run/cbox-init.sock", @@ -50,11 +66,6 @@ func New(baseURL, auth string) *Client { } } - // Fall back to TCP - client.client = &http.Client{ - Timeout: 10 * time.Second, - } - return client } diff --git a/internal/logtail/rotator.go b/internal/logtail/rotator.go index d9ef097..65b94a9 100644 --- a/internal/logtail/rotator.go +++ b/internal/logtail/rotator.go @@ -8,7 +8,8 @@ import ( // FileRotator performs size-based log file rotation. // When a file exceeds MaxSize, it is renamed with a numeric suffix // (app.log -> app.log.1, app.log.1 -> app.log.2, etc.) and the -// original is truncated. Files beyond MaxFiles are deleted. +// original path is recreated as an empty file. Files beyond MaxFiles +// are deleted. type FileRotator struct { MaxSize int64 MaxFiles int @@ -51,7 +52,6 @@ func (r *FileRotator) CheckAndRotate(path string) error { } } } - // Rename current file to .1 if err := os.Rename(path, fmt.Sprintf("%s.1", path)); err != nil { return fmt.Errorf("rename %s: %w", path, err) diff --git a/internal/setup/permissions.go b/internal/setup/permissions.go index ae3ddbf..01b3265 100644 --- a/internal/setup/permissions.go +++ b/internal/setup/permissions.go @@ -3,7 +3,9 @@ package setup import ( "log/slog" "os" + "os/user" "path/filepath" + "strconv" ) // Framework represents a detected PHP framework @@ -16,6 +18,14 @@ const ( FrameworkGeneric Framework = "generic" ) +// Fallback uid/gid when neither env override nor /etc/passwd lookup +// resolves an app user. 82 is www-data on Alpine, but on Debian-based +// images this constant is wrong — preferred path is the lookup below. +const ( + fallbackUID = 82 + fallbackGID = 82 +) + // PermissionManager handles directory creation and permission setup type PermissionManager struct { logger *slog.Logger @@ -58,6 +68,65 @@ func dirExists(path string) bool { return err == nil && info.IsDir() } +// resolveAppUser returns the uid/gid the framework directories should be +// chowned to. Resolution order: +// +// 1. `PUID` / `PGID` environment variables — explicit operator override. +// Either both must be valid integers or both are ignored. +// 2. `/etc/passwd` lookup of `www-data` — handles Debian (uid 33) and +// Alpine (uid 82) without hardcoding either, so the same binary +// produces correct ownership in both baseimages. +// 3. Fallback to `fallbackUID/fallbackGID` (Alpine convention). +// +// The chosen source is logged so the operator can see which path won. +func (pm *PermissionManager) resolveAppUser() (uid, gid int) { + if envUID, envGID, ok := readPuidPgidEnv(); ok { + pm.logger.Info("App user from PUID/PGID env", "uid", envUID, "gid", envGID) + return envUID, envGID + } + + // Warn when the env vars are set but rejected so the operator knows + // their override didn't take effect. + puidStr, pgidStr := os.Getenv("PUID"), os.Getenv("PGID") + if (puidStr != "" && pgidStr == "") || (puidStr == "" && pgidStr != "") { + pm.logger.Warn("Only one of PUID/PGID is set — ignoring env override, both must be provided", + "PUID", puidStr, "PGID", pgidStr) + } else if puidStr != "" && pgidStr != "" { + pm.logger.Warn("PUID/PGID values are not valid non-negative integers — ignoring env override", + "PUID", puidStr, "PGID", pgidStr) + } + + if u, err := user.Lookup("www-data"); err == nil { + uidI, errU := strconv.Atoi(u.Uid) + gidI, errG := strconv.Atoi(u.Gid) + if errU == nil && errG == nil { + pm.logger.Info("App user from /etc/passwd lookup", "user", "www-data", "uid", uidI, "gid", gidI) + return uidI, gidI + } + } + + pm.logger.Warn("Could not resolve app user — falling back to Alpine convention", + "uid", fallbackUID, "gid", fallbackGID, + "hint", "Set PUID/PGID env vars or ensure /etc/passwd has a www-data entry", + ) + return fallbackUID, fallbackGID +} + +// readPuidPgidEnv reads PUID and PGID. Both must be present and parse +// as non-negative integers; otherwise the override is ignored. +func readPuidPgidEnv() (uid, gid int, ok bool) { + puidStr, pgidStr := os.Getenv("PUID"), os.Getenv("PGID") + if puidStr == "" || pgidStr == "" { + return 0, 0, false + } + puid, errU := strconv.Atoi(puidStr) + pgid, errG := strconv.Atoi(pgidStr) + if errU != nil || errG != nil || puid < 0 || pgid < 0 { + return 0, 0, false + } + return puid, pgid, true +} + // Setup creates necessary directories and sets permissions func (pm *PermissionManager) Setup() error { fw := pm.detectFramework() @@ -72,18 +141,21 @@ func (pm *PermissionManager) Setup() error { switch fw { case FrameworkLaravel: - return pm.setupLaravel() + uid, gid := pm.resolveAppUser() + return pm.setupLaravel(uid, gid) case FrameworkSymfony: - return pm.setupSymfony() + uid, gid := pm.resolveAppUser() + return pm.setupSymfony(uid, gid) case FrameworkWordPress: - return pm.setupWordPress() + uid, gid := pm.resolveAppUser() + return pm.setupWordPress(uid, gid) default: pm.logger.Debug("Generic framework, skipping permission setup") return nil } } -func (pm *PermissionManager) setupLaravel() error { +func (pm *PermissionManager) setupLaravel(uid, gid int) error { dirs := []string{ filepath.Join(pm.workdir, "storage", "framework", "sessions"), filepath.Join(pm.workdir, "storage", "framework", "views"), @@ -98,15 +170,13 @@ func (pm *PermissionManager) setupLaravel() error { } } - // Set ownership (www-data UID/GID typically 82 on Alpine, 33 on Debian) - // Note: This will fail silently if not running as root - pm.chownRecursive(filepath.Join(pm.workdir, "storage"), 82, 82) - pm.chownRecursive(filepath.Join(pm.workdir, "bootstrap", "cache"), 82, 82) + pm.chownRecursive(filepath.Join(pm.workdir, "storage"), uid, gid) + pm.chownRecursive(filepath.Join(pm.workdir, "bootstrap", "cache"), uid, gid) return nil } -func (pm *PermissionManager) setupSymfony() error { +func (pm *PermissionManager) setupSymfony(uid, gid int) error { dirs := []string{ filepath.Join(pm.workdir, "var", "cache"), filepath.Join(pm.workdir, "var", "log"), @@ -118,17 +188,17 @@ func (pm *PermissionManager) setupSymfony() error { } } - pm.chownRecursive(filepath.Join(pm.workdir, "var"), 82, 82) + pm.chownRecursive(filepath.Join(pm.workdir, "var"), uid, gid) return nil } -func (pm *PermissionManager) setupWordPress() error { +func (pm *PermissionManager) setupWordPress(uid, gid int) error { dir := filepath.Join(pm.workdir, "wp-content", "uploads") if err := pm.createDir(dir, 0775); err != nil { pm.logger.Warn("Failed to create uploads directory", "error", err) } - pm.chownRecursive(filepath.Join(pm.workdir, "wp-content"), 82, 82) + pm.chownRecursive(filepath.Join(pm.workdir, "wp-content"), uid, gid) return nil } diff --git a/internal/setup/permissions_test.go b/internal/setup/permissions_test.go index f7b7192..7549eea 100644 --- a/internal/setup/permissions_test.go +++ b/internal/setup/permissions_test.go @@ -3,6 +3,7 @@ package setup import ( "log/slog" "os" + "os/user" "path/filepath" "testing" ) @@ -496,3 +497,80 @@ func TestPermissionManager_ChownRecursive_WithError(t *testing.T) { // Test completes successfully if no panic } + +func TestReadPuidPgidEnv(t *testing.T) { + tests := []struct { + name string + puid string + pgid string + wantUID int + wantGID int + wantOk bool + }{ + {name: "both unset", puid: "", pgid: "", wantOk: false}, + {name: "only PUID set", puid: "33", pgid: "", wantOk: false}, + {name: "only PGID set", puid: "", pgid: "33", wantOk: false}, + {name: "valid debian uids", puid: "33", pgid: "33", wantUID: 33, wantGID: 33, wantOk: true}, + {name: "valid alpine uids", puid: "82", pgid: "82", wantUID: 82, wantGID: 82, wantOk: true}, + {name: "PUID non-numeric", puid: "abc", pgid: "33", wantOk: false}, + {name: "PGID non-numeric", puid: "33", pgid: "xyz", wantOk: false}, + {name: "negative PUID rejected", puid: "-1", pgid: "33", wantOk: false}, + {name: "negative PGID rejected", puid: "33", pgid: "-1", wantOk: false}, + {name: "uid 0 (root) is valid", puid: "0", pgid: "0", wantUID: 0, wantGID: 0, wantOk: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("PUID", tt.puid) + t.Setenv("PGID", tt.pgid) + + uid, gid, ok := readPuidPgidEnv() + if ok != tt.wantOk { + t.Errorf("ok = %v, want %v", ok, tt.wantOk) + } + if ok && (uid != tt.wantUID || gid != tt.wantGID) { + t.Errorf("uid,gid = %d,%d, want %d,%d", uid, gid, tt.wantUID, tt.wantGID) + } + }) + } +} + +func TestResolveAppUser_PUIDOverridesPasswdLookup(t *testing.T) { + logger := slog.Default() + pm := NewPermissionManager("/tmp", logger) + + // PUID/PGID set: even on a system where www-data resolves to a + // different uid, the env override wins. + t.Setenv("PUID", "1234") + t.Setenv("PGID", "5678") + + uid, gid := pm.resolveAppUser() + if uid != 1234 || gid != 5678 { + t.Errorf("uid,gid = %d,%d, want 1234,5678 (env override should win)", uid, gid) + } +} + +func TestResolveAppUser_FallsBackWhenLookupFails(t *testing.T) { + logger := slog.Default() + pm := NewPermissionManager("/tmp", logger) + + // Clear env so we don't take the override path. + t.Setenv("PUID", "") + t.Setenv("PGID", "") + + uid, gid := pm.resolveAppUser() + + // Either www-data was on the test host and resolved to a real uid, + // or it wasn't and we fell back to the Alpine convention. Both are + // acceptable; the function never returns invalid values. + if uid < 0 || gid < 0 { + t.Errorf("got negative uid/gid: %d,%d", uid, gid) + } + + // If www-data does not resolve on the test host, the fallback must hit. + if _, err := user.Lookup("www-data"); err != nil { + if uid != fallbackUID || gid != fallbackGID { + t.Errorf("expected fallback %d/%d, got %d/%d", fallbackUID, fallbackGID, uid, gid) + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index c720a26..7561675 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -162,9 +162,14 @@ func NewModel(mgr *process.Manager) Model { // NewRemoteModel creates a new TUI model for remote mode func NewRemoteModel(apiURL, auth string) Model { + client := apiclient.New(apiURL, auth) + if apiURL == "" { + client = apiclient.NewWithAutoDiscover("http://localhost:9180", auth) + } + return Model{ manager: nil, - client: apiclient.New(apiURL, auth), + client: client, isRemote: true, currentView: viewProcessList, processCache: make(map[string]process.ProcessInfo), diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index ada16ec..e341155 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -784,6 +784,9 @@ func TestNewRemoteModel(t *testing.T) { if m.client == nil { t.Error("Expected client to be non-nil for remote mode") } + if m.client.BaseURL() != apiURL { + t.Errorf("Expected explicit baseURL %q, got %q", apiURL, m.client.BaseURL()) + } if m.manager != nil { t.Error("Expected manager to be nil for remote mode") @@ -821,6 +824,9 @@ func TestNewRemoteModel_EmptyURL(t *testing.T) { if m.client == nil { t.Error("Expected client to be non-nil even with empty URL") } + if m.client.BaseURL() != "http://localhost:9180" { + t.Errorf("Expected fallback baseURL %q, got %q", "http://localhost:9180", m.client.BaseURL()) + } } // TestTriggerAction tests the triggerAction method