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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions cmd/cbox-init/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/cbox-init/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 42 additions & 3 deletions cmd/cbox-init/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/signal"
"strings"

"github.com/cboxdk/init/internal/logger"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -82,11 +89,43 @@ func runLogs(cmd *cobra.Command, args []string) {
}

for entry := range ch {
printLogEntry(entry)
if shouldPrintLogEntry(entry, levelFilter) {
printLogEntry(entry)
}
}
}

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
}
73 changes: 73 additions & 0 deletions cmd/cbox-init/logs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 6 additions & 2 deletions cmd/cbox-init/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
Expand Down
41 changes: 41 additions & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/getting-started/docker-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
```
Expand Down
25 changes: 18 additions & 7 deletions internal/apiclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -50,11 +66,6 @@ func New(baseURL, auth string) *Client {
}
}

// Fall back to TCP
client.client = &http.Client{
Timeout: 10 * time.Second,
}

return client
}

Expand Down
4 changes: 2 additions & 2 deletions internal/logtail/rotator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading