From 8f82b95916263b2cea9b062ea64e26d66f665157 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:34:05 +0200 Subject: [PATCH 01/18] refactor: extract API client to internal/apiclient for shared CLI/TUI use Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/{tui => apiclient}/client.go | 60 +++--- internal/{tui => apiclient}/client_test.go | 234 ++++++++++----------- internal/tui/model.go | 5 +- internal/tui/model_test.go | 21 +- internal/tui/update_test.go | 35 +-- 5 files changed, 179 insertions(+), 176 deletions(-) rename internal/{tui => apiclient}/client.go (87%) rename internal/{tui => apiclient}/client_test.go (87%) diff --git a/internal/tui/client.go b/internal/apiclient/client.go similarity index 87% rename from internal/tui/client.go rename to internal/apiclient/client.go index 51fcfdb..5f61449 100644 --- a/internal/tui/client.go +++ b/internal/apiclient/client.go @@ -1,4 +1,4 @@ -package tui +package apiclient import ( "bytes" @@ -18,18 +18,18 @@ import ( "github.com/cboxdk/init/internal/process" ) -// APIClient connects to a running Cbox Init daemon via API -type APIClient struct { +// Client connects to a running Cbox Init daemon via API +type Client struct { baseURL string socketPath string auth string client *http.Client } -// NewAPIClient creates a new API client with auto-detection +// New creates a new API client with auto-detection // Tries Unix socket first, falls back to TCP -func NewAPIClient(baseURL, auth string) *APIClient { - client := &APIClient{ +func New(baseURL, auth string) *Client { + client := &Client{ baseURL: baseURL, auth: auth, } @@ -59,7 +59,7 @@ func NewAPIClient(baseURL, auth string) *APIClient { } // trySocket tests if a socket path is accessible -func (c *APIClient) trySocket(socketPath string) bool { +func (c *Client) trySocket(socketPath string) bool { // Check if socket file exists if _, err := os.Stat(socketPath); os.IsNotExist(err) { return false @@ -76,7 +76,7 @@ func (c *APIClient) trySocket(socketPath string) bool { } // createSocketClient creates an HTTP client that uses Unix socket -func (c *APIClient) createSocketClient(socketPath string) *http.Client { +func (c *Client) createSocketClient(socketPath string) *http.Client { return &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ @@ -88,7 +88,7 @@ func (c *APIClient) createSocketClient(socketPath string) *http.Client { } // getURL constructs the URL for API requests -func (c *APIClient) getURL(path string) string { +func (c *Client) getURL(path string) string { if c.socketPath != "" { // Use dummy hostname for socket connections return fmt.Sprintf("http://unix%s", path) @@ -97,7 +97,7 @@ func (c *APIClient) getURL(path string) string { } // ListProcesses fetches process list from API -func (c *APIClient) ListProcesses() ([]process.ProcessInfo, error) { +func (c *Client) ListProcesses() ([]process.ProcessInfo, error) { url := c.getURL("/api/v1/processes") req, err := http.NewRequest("GET", url, nil) @@ -132,22 +132,22 @@ func (c *APIClient) ListProcesses() ([]process.ProcessInfo, error) { } // StartProcess starts a stopped process -func (c *APIClient) StartProcess(name string) error { +func (c *Client) StartProcess(name string) error { return c.processAction(name, "start") } // StopProcess stops a running process -func (c *APIClient) StopProcess(name string) error { +func (c *Client) StopProcess(name string) error { return c.processAction(name, "stop") } // RestartProcess restarts a process -func (c *APIClient) RestartProcess(name string) error { +func (c *Client) RestartProcess(name string) error { return c.processAction(name, "restart") } // ScaleProcess scales a process -func (c *APIClient) ScaleProcess(name string, desired int) error { +func (c *Client) ScaleProcess(name string, desired int) error { url := c.getURL(fmt.Sprintf("/api/v1/processes/%s/scale", name)) body := fmt.Sprintf(`{"desired":%d}`, desired) @@ -177,7 +177,7 @@ func (c *APIClient) ScaleProcess(name string, desired int) error { } // ScaleProcessDelta adjusts process scale by delta -func (c *APIClient) ScaleProcessDelta(name string, delta int) error { +func (c *Client) ScaleProcessDelta(name string, delta int) error { url := c.getURL(fmt.Sprintf("/api/v1/processes/%s/scale", name)) body := fmt.Sprintf(`{"delta":%d}`, delta) @@ -207,7 +207,7 @@ func (c *APIClient) ScaleProcessDelta(name string, delta int) error { } // processAction performs a process action (start/stop/restart) -func (c *APIClient) processAction(name, action string) error { +func (c *Client) processAction(name, action string) error { url := c.getURL(fmt.Sprintf("/api/v1/processes/%s/%s", name, action)) req, err := http.NewRequest("POST", url, nil) @@ -234,7 +234,7 @@ func (c *APIClient) processAction(name, action string) error { } // DeleteProcess removes a process via API -func (c *APIClient) DeleteProcess(name string) error { +func (c *Client) DeleteProcess(name string) error { url := c.getURL(fmt.Sprintf("/api/v1/processes/%s", name)) req, err := http.NewRequest(http.MethodDelete, url, nil) @@ -261,7 +261,7 @@ func (c *APIClient) DeleteProcess(name string) error { } // UpdateProcess updates an existing process definition -func (c *APIClient) UpdateProcess(name string, proc *config.Process) error { +func (c *Client) UpdateProcess(name string, proc *config.Process) error { if proc == nil { return fmt.Errorf("process configuration is required") } @@ -298,7 +298,7 @@ func (c *APIClient) UpdateProcess(name string, proc *config.Process) error { } // HealthCheck checks if API is reachable -func (c *APIClient) HealthCheck(ctx context.Context) error { +func (c *Client) HealthCheck(ctx context.Context) error { url := c.getURL("/api/v1/health") req, err := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -320,7 +320,7 @@ func (c *APIClient) HealthCheck(ctx context.Context) error { } // AddProcess creates a new process via API -func (c *APIClient) AddProcess(ctx context.Context, name string, command []string, scale int, restart string, enabled bool) error { +func (c *Client) AddProcess(ctx context.Context, name string, command []string, scale int, restart string, enabled bool) error { url := c.getURL("/api/v1/processes") // Build request body matching API expectations @@ -364,7 +364,7 @@ func (c *APIClient) AddProcess(ctx context.Context, name string, command []strin } // GetLogs retrieves logs for a specific process -func (c *APIClient) GetLogs(processName string, limit int) ([]logger.LogEntry, error) { +func (c *Client) GetLogs(processName string, limit int) ([]logger.LogEntry, error) { if processName == "" { return nil, fmt.Errorf("process name is required") } @@ -378,7 +378,7 @@ func (c *APIClient) GetLogs(processName string, limit int) ([]logger.LogEntry, e } // GetStackLogs retrieves aggregated logs for all processes -func (c *APIClient) GetStackLogs(limit int) ([]logger.LogEntry, error) { +func (c *Client) GetStackLogs(limit int) ([]logger.LogEntry, error) { path := "/api/v1/logs" if limit > 0 { path = fmt.Sprintf("%s?limit=%d", path, limit) @@ -386,7 +386,7 @@ func (c *APIClient) GetStackLogs(limit int) ([]logger.LogEntry, error) { return c.fetchLogs(path) } -func (c *APIClient) fetchLogs(path string) ([]logger.LogEntry, error) { +func (c *Client) fetchLogs(path string) ([]logger.LogEntry, error) { if c.client == nil { return nil, fmt.Errorf("API client not initialized") } @@ -423,7 +423,7 @@ func (c *APIClient) fetchLogs(path string) ([]logger.LogEntry, error) { } // GetProcessConfig fetches full configuration for a process -func (c *APIClient) GetProcessConfig(name string) (*config.Process, error) { +func (c *Client) GetProcessConfig(name string) (*config.Process, error) { if name == "" { return nil, fmt.Errorf("process name is required") } @@ -463,22 +463,22 @@ func (c *APIClient) GetProcessConfig(name string) (*config.Process, error) { } // PauseSchedule pauses a scheduled job via API -func (c *APIClient) PauseSchedule(name string) error { +func (c *Client) PauseSchedule(name string) error { return c.processAction(name, "schedule/pause") } // ResumeSchedule resumes a paused scheduled job via API -func (c *APIClient) ResumeSchedule(name string) error { +func (c *Client) ResumeSchedule(name string) error { return c.processAction(name, "schedule/resume") } // TriggerSchedule manually triggers a scheduled job via API -func (c *APIClient) TriggerSchedule(name string) error { +func (c *Client) TriggerSchedule(name string) error { return c.processAction(name, "schedule/trigger") } // ReloadConfig reloads configuration from disk via API -func (c *APIClient) ReloadConfig() error { +func (c *Client) ReloadConfig() error { url := c.getURL("/api/v1/config/reload") req, err := http.NewRequest(http.MethodPost, url, nil) @@ -505,7 +505,7 @@ func (c *APIClient) ReloadConfig() error { } // SaveConfig saves running configuration to file via API -func (c *APIClient) SaveConfig() error { +func (c *Client) SaveConfig() error { url := c.getURL("/api/v1/config/save") req, err := http.NewRequest(http.MethodPost, url, nil) @@ -532,7 +532,7 @@ func (c *APIClient) SaveConfig() error { } // GetOneshotHistory fetches oneshot execution history from API -func (c *APIClient) GetOneshotHistory(limit int) ([]process.OneshotExecution, error) { +func (c *Client) GetOneshotHistory(limit int) ([]process.OneshotExecution, error) { path := "/api/v1/oneshot/history" if limit > 0 { path = fmt.Sprintf("%s?limit=%d", path, limit) diff --git a/internal/tui/client_test.go b/internal/apiclient/client_test.go similarity index 87% rename from internal/tui/client_test.go rename to internal/apiclient/client_test.go index 7830a6f..54fde2b 100644 --- a/internal/tui/client_test.go +++ b/internal/apiclient/client_test.go @@ -1,4 +1,4 @@ -package tui +package apiclient import ( "context" @@ -17,8 +17,8 @@ import ( "github.com/cboxdk/init/internal/process" ) -// TestNewAPIClient tests client creation -func TestNewAPIClient(t *testing.T) { +// TestNew tests client creation +func TestNew(t *testing.T) { tests := []struct { name string baseURL string @@ -43,7 +43,7 @@ func TestNewAPIClient(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := NewAPIClient(tt.baseURL, tt.auth) + client := New(tt.baseURL, tt.auth) if client == nil { t.Fatal("Expected non-nil client") @@ -69,8 +69,8 @@ func TestNewAPIClient(t *testing.T) { } } -// TestAPIClient_getURL tests URL construction -func TestAPIClient_getURL(t *testing.T) { +// TestClient_getURL tests URL construction +func TestClient_getURL(t *testing.T) { tests := []struct { name string baseURL string @@ -103,7 +103,7 @@ func TestAPIClient_getURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := &APIClient{ + client := &Client{ baseURL: tt.baseURL, socketPath: tt.socketPath, } @@ -116,8 +116,8 @@ func TestAPIClient_getURL(t *testing.T) { } } -// TestAPIClient_ListProcesses tests fetching process list -func TestAPIClient_ListProcesses(t *testing.T) { +// TestClient_ListProcesses tests fetching process list +func TestClient_ListProcesses(t *testing.T) { tests := []struct { name string serverResponse interface{} @@ -197,7 +197,7 @@ func TestAPIClient_ListProcesses(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, tt.auth) + client := New(server.URL, tt.auth) processes, err := client.ListProcesses() if (err != nil) != tt.wantErr { @@ -214,7 +214,7 @@ func TestAPIClient_ListProcesses(t *testing.T) { } } -func TestAPIClient_GetLogs(t *testing.T) { +func TestClient_GetLogs(t *testing.T) { expectedLogs := []logger.LogEntry{ { ProcessName: "app", @@ -243,7 +243,7 @@ func TestAPIClient_GetLogs(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "token") + client := New(server.URL, "token") logs, err := client.GetLogs("app", 50) if err != nil { t.Fatalf("GetLogs returned error: %v", err) @@ -253,7 +253,7 @@ func TestAPIClient_GetLogs(t *testing.T) { } } -func TestAPIClient_GetStackLogs(t *testing.T) { +func TestClient_GetStackLogs(t *testing.T) { expectedLogs := []logger.LogEntry{ { ProcessName: "stack", @@ -276,7 +276,7 @@ func TestAPIClient_GetStackLogs(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") logs, err := client.GetStackLogs(0) if err != nil { t.Fatalf("GetStackLogs returned error: %v", err) @@ -286,7 +286,7 @@ func TestAPIClient_GetStackLogs(t *testing.T) { } } -func TestAPIClient_DeleteProcess(t *testing.T) { +func TestClient_DeleteProcess(t *testing.T) { var called bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { @@ -300,7 +300,7 @@ func TestAPIClient_DeleteProcess(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") if err := client.DeleteProcess("app"); err != nil { t.Fatalf("DeleteProcess returned error: %v", err) } @@ -309,7 +309,7 @@ func TestAPIClient_DeleteProcess(t *testing.T) { } } -func TestAPIClient_UpdateProcess(t *testing.T) { +func TestClient_UpdateProcess(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { t.Fatalf("expected PUT, got %s", r.Method) @@ -327,7 +327,7 @@ func TestAPIClient_UpdateProcess(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") proc := &config.Process{ Enabled: true, Command: []string{"php"}, @@ -339,7 +339,7 @@ func TestAPIClient_UpdateProcess(t *testing.T) { } } -func TestAPIClient_GetProcessConfig(t *testing.T) { +func TestClient_GetProcessConfig(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/processes/app" { t.Fatalf("unexpected path: %s", r.URL.Path) @@ -356,7 +356,7 @@ func TestAPIClient_GetProcessConfig(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") cfg, err := client.GetProcessConfig("app") if err != nil { t.Fatalf("GetProcessConfig returned error: %v", err) @@ -366,8 +366,8 @@ func TestAPIClient_GetProcessConfig(t *testing.T) { } } -// TestAPIClient_StartProcess tests starting a process -func TestAPIClient_StartProcess(t *testing.T) { +// TestClient_StartProcess tests starting a process +func TestClient_StartProcess(t *testing.T) { tests := []struct { name string processName string @@ -419,7 +419,7 @@ func TestAPIClient_StartProcess(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.StartProcess(tt.processName) if (err != nil) != tt.wantErr { @@ -429,8 +429,8 @@ func TestAPIClient_StartProcess(t *testing.T) { } } -// TestAPIClient_StopProcess tests stopping a process -func TestAPIClient_StopProcess(t *testing.T) { +// TestClient_StopProcess tests stopping a process +func TestClient_StopProcess(t *testing.T) { tests := []struct { name string processName string @@ -463,7 +463,7 @@ func TestAPIClient_StopProcess(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.StopProcess(tt.processName) if (err != nil) != tt.wantErr { @@ -473,8 +473,8 @@ func TestAPIClient_StopProcess(t *testing.T) { } } -// TestAPIClient_RestartProcess tests restarting a process -func TestAPIClient_RestartProcess(t *testing.T) { +// TestClient_RestartProcess tests restarting a process +func TestClient_RestartProcess(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/processes/test-proc/restart" { t.Errorf("Expected path /api/v1/processes/test-proc/restart, got %s", r.URL.Path) @@ -488,7 +488,7 @@ func TestAPIClient_RestartProcess(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.RestartProcess("test-proc") if err != nil { @@ -496,8 +496,8 @@ func TestAPIClient_RestartProcess(t *testing.T) { } } -// TestAPIClient_ScaleProcess tests scaling a process -func TestAPIClient_ScaleProcess(t *testing.T) { +// TestClient_ScaleProcess tests scaling a process +func TestClient_ScaleProcess(t *testing.T) { tests := []struct { name string processName string @@ -562,7 +562,7 @@ func TestAPIClient_ScaleProcess(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.ScaleProcess(tt.processName, tt.desired) if (err != nil) != tt.wantErr { @@ -572,8 +572,8 @@ func TestAPIClient_ScaleProcess(t *testing.T) { } } -// TestAPIClient_HealthCheck tests health check endpoint -func TestAPIClient_HealthCheck(t *testing.T) { +// TestClient_HealthCheck tests health check endpoint +func TestClient_HealthCheck(t *testing.T) { tests := []struct { name string serverStatus int @@ -609,7 +609,7 @@ func TestAPIClient_HealthCheck(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") ctx, cancel := context.WithTimeout(context.Background(), tt.timeout) defer cancel() @@ -623,8 +623,8 @@ func TestAPIClient_HealthCheck(t *testing.T) { } } -// TestAPIClient_AddProcess tests adding a new process -func TestAPIClient_AddProcess(t *testing.T) { +// TestClient_AddProcess tests adding a new process +func TestClient_AddProcess(t *testing.T) { tests := []struct { name string processName string @@ -712,7 +712,7 @@ func TestAPIClient_AddProcess(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -726,8 +726,8 @@ func TestAPIClient_AddProcess(t *testing.T) { } } -// TestAPIClient_ListProcesses_WithAuth tests authentication header -func TestAPIClient_ListProcesses_WithAuth(t *testing.T) { +// TestClient_ListProcesses_WithAuth tests authentication header +func TestClient_ListProcesses_WithAuth(t *testing.T) { expectedAuth := "test-token-123" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -745,7 +745,7 @@ func TestAPIClient_ListProcesses_WithAuth(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, expectedAuth) + client := New(server.URL, expectedAuth) _, err := client.ListProcesses() if err != nil { @@ -753,8 +753,8 @@ func TestAPIClient_ListProcesses_WithAuth(t *testing.T) { } } -// TestAPIClient_ScaleProcessDelta tests delta scaling -func TestAPIClient_ScaleProcessDelta(t *testing.T) { +// TestClient_ScaleProcessDelta tests delta scaling +func TestClient_ScaleProcessDelta(t *testing.T) { tests := []struct { name string processName string @@ -824,7 +824,7 @@ func TestAPIClient_ScaleProcessDelta(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.ScaleProcessDelta(tt.processName, tt.delta) if (err != nil) != tt.wantErr { @@ -834,8 +834,8 @@ func TestAPIClient_ScaleProcessDelta(t *testing.T) { } } -// TestAPIClient_trySocket tests socket detection logic -func TestAPIClient_trySocket(t *testing.T) { +// TestClient_trySocket tests socket detection logic +func TestClient_trySocket(t *testing.T) { tests := []struct { name string socketPath string @@ -855,7 +855,7 @@ func TestAPIClient_trySocket(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := &APIClient{} + client := &Client{} result := client.trySocket(tt.socketPath) if result != tt.wantResult { @@ -881,7 +881,7 @@ func TestAPIClient_trySocket(t *testing.T) { defer listener.Close() // Test that trySocket succeeds with a real socket - client := &APIClient{} + client := &Client{} result := client.trySocket(socketPath) if !result { @@ -890,9 +890,9 @@ func TestAPIClient_trySocket(t *testing.T) { }) } -// TestAPIClient_createSocketClient tests socket client creation -func TestAPIClient_createSocketClient(t *testing.T) { - client := &APIClient{} +// TestClient_createSocketClient tests socket client creation +func TestClient_createSocketClient(t *testing.T) { + client := &Client{} socketPath := "/tmp/test.sock" httpClient := client.createSocketClient(socketPath) @@ -932,7 +932,7 @@ func TestAPIClient_createSocketClient(t *testing.T) { defer server.Close() // Create client with socket - apiClient := &APIClient{} + apiClient := &Client{} httpClient := apiClient.createSocketClient(socketPath) // Make request through socket client @@ -953,8 +953,8 @@ func TestAPIClient_createSocketClient(t *testing.T) { }) } -// TestAPIClient_DeleteProcess_ErrorPaths tests error handling in DeleteProcess -func TestAPIClient_DeleteProcess_ErrorPaths(t *testing.T) { +// TestClient_DeleteProcess_ErrorPaths tests error handling in DeleteProcess +func TestClient_DeleteProcess_ErrorPaths(t *testing.T) { tests := []struct { name string serverResponse string @@ -993,7 +993,7 @@ func TestAPIClient_DeleteProcess_ErrorPaths(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.DeleteProcess("test-process") if (err != nil) != tt.wantErr { @@ -1007,9 +1007,9 @@ func TestAPIClient_DeleteProcess_ErrorPaths(t *testing.T) { } } -// TestAPIClient_DeleteProcess_NetworkError tests network failure handling -func TestAPIClient_DeleteProcess_NetworkError(t *testing.T) { - client := &APIClient{ +// TestClient_DeleteProcess_NetworkError tests network failure handling +func TestClient_DeleteProcess_NetworkError(t *testing.T) { + client := &Client{ baseURL: "http://localhost:0", // Invalid port client: &http.Client{Timeout: 100 * time.Millisecond}, } @@ -1024,8 +1024,8 @@ func TestAPIClient_DeleteProcess_NetworkError(t *testing.T) { } } -// TestAPIClient_UpdateProcess_ErrorPaths tests error handling in UpdateProcess -func TestAPIClient_UpdateProcess_ErrorPaths(t *testing.T) { +// TestClient_UpdateProcess_ErrorPaths tests error handling in UpdateProcess +func TestClient_UpdateProcess_ErrorPaths(t *testing.T) { tests := []struct { name string processConfig *config.Process @@ -1089,11 +1089,11 @@ func TestAPIClient_UpdateProcess_ErrorPaths(t *testing.T) { defer server.Close() } - var client *APIClient + var client *Client if server != nil { - client = NewAPIClient(server.URL, "") + client = New(server.URL, "") } else { - client = NewAPIClient("http://localhost:9999", "") + client = New("http://localhost:9999", "") } err := client.UpdateProcess("test-process", tt.processConfig) @@ -1109,9 +1109,9 @@ func TestAPIClient_UpdateProcess_ErrorPaths(t *testing.T) { } } -// TestAPIClient_UpdateProcess_NetworkError tests network failure -func TestAPIClient_UpdateProcess_NetworkError(t *testing.T) { - client := &APIClient{ +// TestClient_UpdateProcess_NetworkError tests network failure +func TestClient_UpdateProcess_NetworkError(t *testing.T) { + client := &Client{ baseURL: "http://localhost:0", client: &http.Client{Timeout: 100 * time.Millisecond}, } @@ -1132,11 +1132,11 @@ func TestAPIClient_UpdateProcess_NetworkError(t *testing.T) { } } -// TestAPIClient_fetchLogs_ErrorPaths tests error handling in fetchLogs -func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { +// TestClient_fetchLogs_ErrorPaths tests error handling in fetchLogs +func TestClient_fetchLogs_ErrorPaths(t *testing.T) { tests := []struct { name string - clientSetup func() *APIClient + clientSetup func() *Client path string serverStatus int serverResponse string @@ -1145,8 +1145,8 @@ func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { }{ { name: "nil HTTP client", - clientSetup: func() *APIClient { - return &APIClient{client: nil} + clientSetup: func() *Client { + return &Client{client: nil} }, path: "/api/v1/logs", wantErr: true, @@ -1154,7 +1154,7 @@ func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { }, { name: "HTTP error status", - clientSetup: func() *APIClient { + clientSetup: func() *Client { return nil // Will be set by test }, path: "/api/v1/logs", @@ -1165,7 +1165,7 @@ func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { }, { name: "invalid JSON response", - clientSetup: func() *APIClient { + clientSetup: func() *Client { return nil // Will be set by test }, path: "/api/v1/logs", @@ -1176,7 +1176,7 @@ func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { }, { name: "internal server error", - clientSetup: func() *APIClient { + clientSetup: func() *Client { return nil }, path: "/api/v1/logs", @@ -1189,7 +1189,7 @@ func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var client *APIClient + var client *Client if tt.clientSetup != nil && tt.clientSetup() != nil { client = tt.clientSetup() @@ -1199,7 +1199,7 @@ func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { _, _ = w.Write([]byte(tt.serverResponse)) })) defer server.Close() - client = NewAPIClient(server.URL, "") + client = New(server.URL, "") } _, err := client.fetchLogs(tt.path) @@ -1215,9 +1215,9 @@ func TestAPIClient_fetchLogs_ErrorPaths(t *testing.T) { } } -// TestAPIClient_fetchLogs_NetworkError tests network failure -func TestAPIClient_fetchLogs_NetworkError(t *testing.T) { - client := &APIClient{ +// TestClient_fetchLogs_NetworkError tests network failure +func TestClient_fetchLogs_NetworkError(t *testing.T) { + client := &Client{ baseURL: "http://localhost:0", client: &http.Client{Timeout: 100 * time.Millisecond}, } @@ -1232,9 +1232,9 @@ func TestAPIClient_fetchLogs_NetworkError(t *testing.T) { } } -// TestAPIClient_GetLogs_EmptyProcessName tests empty process name validation -func TestAPIClient_GetLogs_EmptyProcessName(t *testing.T) { - client := NewAPIClient("http://localhost:9999", "") +// TestClient_GetLogs_EmptyProcessName tests empty process name validation +func TestClient_GetLogs_EmptyProcessName(t *testing.T) { + client := New("http://localhost:9999", "") _, err := client.GetLogs("", 10) if err == nil { @@ -1246,8 +1246,8 @@ func TestAPIClient_GetLogs_EmptyProcessName(t *testing.T) { } } -// TestAPIClient_GetProcessConfig_ErrorPaths tests error handling in GetProcessConfig -func TestAPIClient_GetProcessConfig_ErrorPaths(t *testing.T) { +// TestClient_GetProcessConfig_ErrorPaths tests error handling in GetProcessConfig +func TestClient_GetProcessConfig_ErrorPaths(t *testing.T) { tests := []struct { name string processName string @@ -1307,11 +1307,11 @@ func TestAPIClient_GetProcessConfig_ErrorPaths(t *testing.T) { defer server.Close() } - var client *APIClient + var client *Client if server != nil { - client = NewAPIClient(server.URL, "") + client = New(server.URL, "") } else { - client = NewAPIClient("http://localhost:9999", "") + client = New("http://localhost:9999", "") } _, err := client.GetProcessConfig(tt.processName) @@ -1327,9 +1327,9 @@ func TestAPIClient_GetProcessConfig_ErrorPaths(t *testing.T) { } } -// TestAPIClient_GetProcessConfig_NetworkError tests network failure -func TestAPIClient_GetProcessConfig_NetworkError(t *testing.T) { - client := &APIClient{ +// TestClient_GetProcessConfig_NetworkError tests network failure +func TestClient_GetProcessConfig_NetworkError(t *testing.T) { + client := &Client{ baseURL: "http://localhost:0", client: &http.Client{Timeout: 100 * time.Millisecond}, } @@ -1344,15 +1344,15 @@ func TestAPIClient_GetProcessConfig_NetworkError(t *testing.T) { } } -// TestAPIClient_ListProcesses_InvalidJSON tests invalid JSON response handling -func TestAPIClient_ListProcesses_InvalidJSON(t *testing.T) { +// TestClient_ListProcesses_InvalidJSON tests invalid JSON response handling +func TestClient_ListProcesses_InvalidJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`invalid json{`)) })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") _, err := client.ListProcesses() if err == nil { @@ -1364,8 +1364,8 @@ func TestAPIClient_ListProcesses_InvalidJSON(t *testing.T) { } } -// TestAPIClient_ReloadConfig tests configuration reload via API -func TestAPIClient_ReloadConfig(t *testing.T) { +// TestClient_ReloadConfig tests configuration reload via API +func TestClient_ReloadConfig(t *testing.T) { tests := []struct { name string statusCode int @@ -1400,7 +1400,7 @@ func TestAPIClient_ReloadConfig(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.ReloadConfig() if (err != nil) != tt.wantErr { @@ -1410,8 +1410,8 @@ func TestAPIClient_ReloadConfig(t *testing.T) { } } -// TestAPIClient_SaveConfig tests configuration save via API -func TestAPIClient_SaveConfig(t *testing.T) { +// TestClient_SaveConfig tests configuration save via API +func TestClient_SaveConfig(t *testing.T) { tests := []struct { name string statusCode int @@ -1446,7 +1446,7 @@ func TestAPIClient_SaveConfig(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.SaveConfig() if (err != nil) != tt.wantErr { @@ -1456,8 +1456,8 @@ func TestAPIClient_SaveConfig(t *testing.T) { } } -// TestAPIClient_PauseSchedule tests pausing scheduled jobs via API -func TestAPIClient_PauseSchedule(t *testing.T) { +// TestClient_PauseSchedule tests pausing scheduled jobs via API +func TestClient_PauseSchedule(t *testing.T) { tests := []struct { name string processName string @@ -1509,7 +1509,7 @@ func TestAPIClient_PauseSchedule(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.PauseSchedule(tt.processName) if (err != nil) != tt.wantErr { @@ -1519,8 +1519,8 @@ func TestAPIClient_PauseSchedule(t *testing.T) { } } -// TestAPIClient_ResumeSchedule tests resuming paused scheduled jobs via API -func TestAPIClient_ResumeSchedule(t *testing.T) { +// TestClient_ResumeSchedule tests resuming paused scheduled jobs via API +func TestClient_ResumeSchedule(t *testing.T) { tests := []struct { name string processName string @@ -1572,7 +1572,7 @@ func TestAPIClient_ResumeSchedule(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.ResumeSchedule(tt.processName) if (err != nil) != tt.wantErr { @@ -1582,8 +1582,8 @@ func TestAPIClient_ResumeSchedule(t *testing.T) { } } -// TestAPIClient_TriggerSchedule tests manually triggering scheduled jobs via API -func TestAPIClient_TriggerSchedule(t *testing.T) { +// TestClient_TriggerSchedule tests manually triggering scheduled jobs via API +func TestClient_TriggerSchedule(t *testing.T) { tests := []struct { name string processName string @@ -1635,7 +1635,7 @@ func TestAPIClient_TriggerSchedule(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") err := client.TriggerSchedule(tt.processName) if (err != nil) != tt.wantErr { @@ -1645,9 +1645,9 @@ func TestAPIClient_TriggerSchedule(t *testing.T) { } } -// TestAPIClient_ScheduleActions_NetworkError tests network failure handling for schedule actions -func TestAPIClient_ScheduleActions_NetworkError(t *testing.T) { - client := &APIClient{ +// TestClient_ScheduleActions_NetworkError tests network failure handling for schedule actions +func TestClient_ScheduleActions_NetworkError(t *testing.T) { + client := &Client{ baseURL: "http://localhost:0", // Invalid port client: &http.Client{Timeout: 100 * time.Millisecond}, } @@ -1671,8 +1671,8 @@ func TestAPIClient_ScheduleActions_NetworkError(t *testing.T) { } } -// TestAPIClient_ScheduleActions_WithAuth tests schedule actions with authentication -func TestAPIClient_ScheduleActions_WithAuth(t *testing.T) { +// TestClient_ScheduleActions_WithAuth tests schedule actions with authentication +func TestClient_ScheduleActions_WithAuth(t *testing.T) { expectedAuth := "schedule-token-123" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1686,7 +1686,7 @@ func TestAPIClient_ScheduleActions_WithAuth(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, expectedAuth) + client := New(server.URL, expectedAuth) // Test PauseSchedule with auth err := client.PauseSchedule("test-cron") @@ -1707,8 +1707,8 @@ func TestAPIClient_ScheduleActions_WithAuth(t *testing.T) { } } -// TestAPIClient_GetOneshotHistory tests oneshot history retrieval via API -func TestAPIClient_GetOneshotHistory(t *testing.T) { +// TestClient_GetOneshotHistory tests oneshot history retrieval via API +func TestClient_GetOneshotHistory(t *testing.T) { tests := []struct { name string limit int @@ -1776,7 +1776,7 @@ func TestAPIClient_GetOneshotHistory(t *testing.T) { })) defer server.Close() - client := NewAPIClient(server.URL, "") + client := New(server.URL, "") results, err := client.GetOneshotHistory(tt.limit) if (err != nil) != tt.wantErr { diff --git a/internal/tui/model.go b/internal/tui/model.go index d085afb..c720a26 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/cboxdk/init/internal/apiclient" "github.com/cboxdk/init/internal/config" "github.com/cboxdk/init/internal/process" ) @@ -71,7 +72,7 @@ const ( // Model is the main Bubbletea model for the TUI type Model struct { manager *process.Manager // For embedded mode - client *APIClient // For remote mode + client *apiclient.Client // For remote mode isRemote bool // true if using API client currentView viewMode activeTab tabType // k9s-style tab selection @@ -163,7 +164,7 @@ func NewModel(mgr *process.Manager) Model { func NewRemoteModel(apiURL, auth string) Model { return Model{ manager: nil, - client: NewAPIClient(apiURL, auth), + client: apiclient.New(apiURL, auth), 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 72518f6..ada16ec 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/cboxdk/init/internal/apiclient" "github.com/cboxdk/init/internal/config" "github.com/cboxdk/init/internal/process" ) @@ -854,7 +855,7 @@ func TestTriggerAction(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } cmd := m.triggerAction(tt.action, tt.target) @@ -1241,7 +1242,7 @@ func TestDispatchAction(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Model{ isRemote: tt.isRemote, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.dispatchAction(tt.action, tt.target) @@ -1264,7 +1265,7 @@ func TestExecuteRestart(t *testing.T) { // Test remote mode - returns message even if client request fails m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.executeRestart("php-fpm") @@ -1280,7 +1281,7 @@ func TestExecuteStop(t *testing.T) { // Test remote mode - returns message even if client request fails m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.executeStop("nginx") @@ -1296,7 +1297,7 @@ func TestExecuteStart(t *testing.T) { // Test remote mode - returns message even if client request fails m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.executeStart("worker") @@ -1312,7 +1313,7 @@ func TestExecuteDelete(t *testing.T) { // Test remote mode - returns message even if client request fails m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.executeDelete("old-process") @@ -1328,7 +1329,7 @@ func TestExecuteSchedulePause(t *testing.T) { // Test remote mode - returns message even if client request fails m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.executeSchedulePause("cron-job") @@ -1344,7 +1345,7 @@ func TestExecuteScheduleResume(t *testing.T) { // Test remote mode - returns message even if client request fails m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.executeScheduleResume("cron-job") @@ -1360,7 +1361,7 @@ func TestExecuteScheduleTrigger(t *testing.T) { // Test remote mode - returns message even if client request fails m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } msg, _ := m.executeScheduleTrigger("cron-job") @@ -1392,7 +1393,7 @@ func TestInit(t *testing.T) { m := &Model{ processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } cmd := m.Init() diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 2fbc567..4176a03 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -5,6 +5,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/cboxdk/init/internal/apiclient" "github.com/cboxdk/init/internal/config" "github.com/cboxdk/init/internal/logger" "github.com/cboxdk/init/internal/process" @@ -1814,7 +1815,7 @@ func TestExecuteSystemAction(t *testing.T) { m := &Model{ systemMenuIndex: tt.menuIndex, isRemote: tt.isRemote, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } cmd := m.executeSystemAction() @@ -2425,7 +2426,7 @@ func TestHandleProcessActionKeys(t *testing.T) { currentView: viewProcessList, processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } if tt.hasData { @@ -2552,7 +2553,7 @@ func TestHandleSystemTabKeys(t *testing.T) { m := Model{ activeTab: tt.activeTab, isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } handled, _, cmd := m.handleSystemTabKeys(tt.key) @@ -2752,7 +2753,7 @@ func TestExecuteScale(t *testing.T) { scaleInput: tt.scaleInput, pendingTarget: tt.target, isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } cmd := m.executeScale() @@ -2920,7 +2921,7 @@ func TestUpdate(t *testing.T) { currentView: viewProcessList, processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } m.setupProcessTable() @@ -2973,7 +2974,7 @@ func TestHandleQuickScale(t *testing.T) { currentView: viewProcessList, processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } if tt.hasSelection { @@ -3032,7 +3033,7 @@ func TestOpenLogView(t *testing.T) { m := &Model{ currentView: viewProcessList, isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), processCache: make(map[string]process.ProcessInfo), } @@ -3264,7 +3265,7 @@ func TestFetchProcessConfigCmd(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Model{ isRemote: tt.isRemote, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } cmd := m.fetchProcessConfigCmd(tt.procName) @@ -3284,7 +3285,7 @@ func TestFetchProcessConfigCmd(t *testing.T) { func TestScaleProcess(t *testing.T) { m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } cmd := m.scaleProcess("php-fpm", 3) @@ -3562,7 +3563,7 @@ func TestHandleProcessActionKeysWithData(t *testing.T) { processCache: make(map[string]process.ProcessInfo), tableData: tt.processData, isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } m.setupProcessTable() @@ -3771,7 +3772,7 @@ func TestHandleQuickScaleAdditional(t *testing.T) { currentView: viewProcessList, processCache: make(map[string]process.ProcessInfo), isRemote: tt.isRemote, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } if tt.hasProcess { @@ -3848,7 +3849,7 @@ func TestHandleProcessDetailKeysAdditional(t *testing.T) { detailProc: tt.detailProc, processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } m.setupProcessTable() m.setupInstanceTable() @@ -3910,7 +3911,7 @@ func TestHandleScheduleKeysAdditional(t *testing.T) { currentView: viewProcessList, processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } if tt.hasData { @@ -3964,7 +3965,7 @@ func TestExecuteActions(t *testing.T) { pendingAction: tt.action, pendingTarget: "php-fpm", isRemote: tt.isRemote, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } cmd := m.executeAction() @@ -3982,7 +3983,7 @@ func TestUpdateAdditionalMessages(t *testing.T) { currentView: viewProcessList, processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } m.setupProcessTable() @@ -4002,7 +4003,7 @@ func TestUpdateAdditionalMessages(t *testing.T) { currentView: viewLogs, processCache: make(map[string]process.ProcessInfo), isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } m.setupProcessTable() @@ -4145,7 +4146,7 @@ func TestExecuteScheduleActions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := &Model{ isRemote: true, - client: NewAPIClient("http://localhost:9999", ""), + client: apiclient.New("http://localhost:9999", ""), } // These functions will fail because they try to call a remote API From fa4c0d8a41d4af9ac7752c22b08d5669e3f36d7f Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:38:40 +0200 Subject: [PATCH 02/18] feat: always-on Unix socket for local management without TCP The daemon now always starts a Unix socket listener regardless of the api_enabled config setting. This ensures local CLI tools and the TUI can communicate with the daemon even when the TCP API port is not exposed. When api_enabled is true, both TCP and socket start as before. When api_enabled is false, only the socket listener starts (socket-only mode) with no ACL or rate limiting since file permissions provide security. Socket permissions changed from 0600 to 0660 to allow group access. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/cbox-init/serve.go | 42 ++++++++++++++++++++++++++++++++++++- cmd/cbox-init/tui.go | 5 ++--- internal/api/server.go | 29 ++++++++++++++++++++++--- internal/api/server_test.go | 6 +++--- 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/cmd/cbox-init/serve.go b/cmd/cbox-init/serve.go index 82f7643..a6ba005 100644 --- a/cmd/cbox-init/serve.go +++ b/cmd/cbox-init/serve.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "os/signal" + "path/filepath" "strconv" "syscall" "time" @@ -173,10 +174,25 @@ func runServe(cmd *cobra.Command, args []string) { // Monitor process health pm.MonitorProcessHealth(ctx) - // Start API server + // Always start Unix socket for local management (TUI, CLI commands) + socketPath := resolveSocketPath(cfg.Global.APISocket) + var apiServer *api.Server if cfg.Global.APIEnabledValue() { + // Full API: TCP + socket + if cfg.Global.APISocket == "" { + cfg.Global.APISocket = socketPath + } apiServer = startAPIServer(ctx, cfg, pm, log) + } else { + // Socket-only mode: local management without TCP + apiServer = api.NewServer(0, socketPath, "", nil, nil, cfg.Global.AuditEnabled, cfg.Global.APIMaxRequestBody, pm, log) + if err := apiServer.StartSocketOnly(ctx); err != nil { + slog.Warn("Failed to start Unix socket (local CLI/TUI disabled)", "error", err) + apiServer = nil + } else { + slog.Info("Unix socket started (local management only)", "path", socketPath) + } } // Start config watcher in watch mode @@ -379,6 +395,30 @@ func startMetricsServer(ctx context.Context, cfg *config.Config, log *slog.Logge return server } +// resolveSocketPath determines the Unix socket path. +// Priority: config value > /var/run/cbox-init.sock > /tmp/cbox-init.sock +func resolveSocketPath(configured string) string { + if configured != "" { + return configured + } + testPath := "/var/run/cbox-init.sock" + if dir := filepath.Dir(testPath); dirWritable(dir) { + return testPath + } + return "/tmp/cbox-init.sock" +} + +func dirWritable(dir string) bool { + testFile := filepath.Join(dir, ".cbox-init-write-test") + f, err := os.Create(testFile) + if err != nil { + return false + } + f.Close() + os.Remove(testFile) + return true +} + // startAPIServer starts the Management API server func startAPIServer(ctx context.Context, cfg *config.Config, pm *process.Manager, log *slog.Logger) *api.Server { apiPort := cfg.Global.APIPort diff --git a/cmd/cbox-init/tui.go b/cmd/cbox-init/tui.go index 17f4d9b..d9e59f8 100644 --- a/cmd/cbox-init/tui.go +++ b/cmd/cbox-init/tui.go @@ -54,9 +54,8 @@ func runTUIRemote(apiURL string) { if err := tui.RunRemote(apiURL, auth); err != nil { fmt.Fprintf(os.Stderr, "āŒ Remote TUI error: %v\n", err) fmt.Fprintf(os.Stderr, "\nšŸ’” Make sure daemon is running:\n") - fmt.Fprintf(os.Stderr, " Terminal 1: cbox-init serve\n") - fmt.Fprintf(os.Stderr, " Terminal 2: cbox-init tui\n\n") - fmt.Fprintf(os.Stderr, "šŸ’” Ensure API is enabled in config:\n") + fmt.Fprintf(os.Stderr, " cbox-init serve\n\n") + fmt.Fprintf(os.Stderr, "šŸ’” For remote access, ensure API is enabled:\n") fmt.Fprintf(os.Stderr, " global:\n") fmt.Fprintf(os.Stderr, " api_enabled: true\n") fmt.Fprintf(os.Stderr, " api_port: 9180\n") diff --git a/internal/api/server.go b/internal/api/server.go index c213e42..fdc1087 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -340,6 +340,29 @@ func (s *Server) Start(ctx context.Context) error { return nil } +// StartSocketOnly starts only the Unix socket listener (no TCP). +// Used when api_enabled is false but local management is still needed. +// The socket handler has no ACL or rate limiting — file permissions provide security. +func (s *Server) StartSocketOnly(ctx context.Context) error { + if s.socketPath == "" { + return fmt.Errorf("no socket path configured") + } + + mux := http.NewServeMux() + + // Same routes as Start() but without rate limiting or ACL + mux.HandleFunc("/api/v1/health", s.wrapHandler(s.handleHealth, false)) + mux.HandleFunc("/api/v1/processes", s.wrapHandler(s.handleProcesses, false)) + mux.HandleFunc("/api/v1/processes/", s.wrapHandler(s.handleProcessAction, false)) + mux.HandleFunc("/api/v1/logs", s.wrapHandler(s.handleStackLogs, false)) + mux.HandleFunc("/api/v1/config/save", s.wrapHandler(s.handleConfigSave, false)) + mux.HandleFunc("/api/v1/config/reload", s.wrapHandler(s.handleConfigReload, false)) + mux.HandleFunc("/api/v1/metrics/history", s.wrapHandler(s.handleMetricsHistory, false)) + mux.HandleFunc("/api/v1/oneshot/history", s.wrapHandler(s.handleOneshotHistory, false)) + + return s.startSocketListener(mux) +} + // startSocketListener starts the Unix socket listener func (s *Server) startSocketListener(handler http.Handler) error { // Remove existing socket file if it exists @@ -353,8 +376,8 @@ func (s *Server) startSocketListener(handler http.Handler) error { return fmt.Errorf("failed to create socket listener: %w", err) } - // Set socket permissions (0600 = owner only) - if err := os.Chmod(s.socketPath, 0600); err != nil { + // Set socket permissions (0660 = owner + group) + if err := os.Chmod(s.socketPath, 0660); err != nil { listener.Close() return fmt.Errorf("failed to set socket permissions: %w", err) } @@ -371,7 +394,7 @@ func (s *Server) startSocketListener(handler http.Handler) error { s.logger.Info("Starting API server (Unix socket)", "path", s.socketPath, - "permissions", "0600", + "permissions", "0660", ) // Start socket server in background diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 733758f..7cee724 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1003,9 +1003,9 @@ func TestServer_StartSocketListener(t *testing.T) { t.Fatalf("Socket file not created: %v", statErr) } - // Check permissions (should be 0600) - if info.Mode().Perm() != 0600 { - t.Errorf("Expected permissions 0600, got %o", info.Mode().Perm()) + // Check permissions (should be 0660 for owner + group access) + if info.Mode().Perm() != 0660 { + t.Errorf("Expected permissions 0660, got %o", info.Mode().Perm()) } // Clean up server From 7a71c4b556d69220f7fcfe54e46eda4623a8a97f Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:42:04 +0200 Subject: [PATCH 03/18] feat: add list and status CLI commands Add `cbox-init list` to display a table of all managed processes with state, scale, restart count, and uptime. Add `cbox-init status ` for detailed single-process info including instances, resource usage, and schedule state. Both commands use the shared apiclient with auto-discovery of Unix sockets and --url flag override. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/cbox-init/list.go | 98 ++++++++++++++++++++++++++++++++++++ cmd/cbox-init/status.go | 108 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 cmd/cbox-init/list.go create mode 100644 cmd/cbox-init/status.go diff --git a/cmd/cbox-init/list.go b/cmd/cbox-init/list.go new file mode 100644 index 0000000..4ad3ce8 --- /dev/null +++ b/cmd/cbox-init/list.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/cboxdk/init/internal/apiclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all processes and their status", + Long: `Display a table of all managed processes with their current state, scale, restart count, and uptime.`, + Args: cobra.NoArgs, + Run: runList, +} + +var listURL string + +func init() { + listCmd.Flags().StringVar(&listURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runList(cmd *cobra.Command, args []string) { + client := newClient(listURL) + + processes, err := client.ListProcesses() + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to list processes: %v\n", err) + os.Exit(1) + } + + if len(processes) == 0 { + fmt.Println("No processes configured") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATUS\tSCALE\tRESTARTS\tUPTIME") + + hasUnhealthy := false + for _, p := range processes { + status := p.State + if status != "running" { + hasUnhealthy = true + } + + scale := fmt.Sprintf("%d/%d", p.Scale, p.DesiredScale) + + restarts := 0 + var uptime string + for _, inst := range p.Instances { + restarts += inst.RestartCount + if inst.StartedAt > 0 && uptime == "" { + d := time.Since(time.Unix(inst.StartedAt, 0)) + uptime = formatDuration(d) + } + } + if uptime == "" { + uptime = "-" + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", p.Name, status, scale, restarts, uptime) + } + w.Flush() + + if hasUnhealthy { + os.Exit(1) + } +} + +// newClient creates an API client, using --url flag or auto-discovery +func newClient(urlFlag string) *apiclient.Client { + auth := os.Getenv("CBOX_INIT_API_AUTH") + if urlFlag != "" { + return apiclient.New(urlFlag, auth) + } + return apiclient.New("http://localhost:9180", auth) +} + +// formatDuration formats a duration as human-readable +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) + } + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%dd%dh", days, hours) +} diff --git a/cmd/cbox-init/status.go b/cmd/cbox-init/status.go new file mode 100644 index 0000000..2daa382 --- /dev/null +++ b/cmd/cbox-init/status.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status ", + Short: "Show detailed status of a process", + Long: `Display detailed information about a specific process including PID, scale, restarts, uptime, command, and health status.`, + Args: cobra.ExactArgs(1), + Run: runStatus, +} + +var statusURL string + +func init() { + statusCmd.Flags().StringVar(&statusURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runStatus(cmd *cobra.Command, args []string) { + processName := args[0] + client := newClient(statusURL) + + processes, err := client.ListProcesses() + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to get process status: %v\n", err) + os.Exit(1) + } + + var found bool + for _, p := range processes { + if p.Name != processName { + continue + } + found = true + + fmt.Printf("Name: %s\n", p.Name) + fmt.Printf("Type: %s\n", p.Type) + fmt.Printf("Status: %s\n", p.State) + fmt.Printf("Scale: %d/%d\n", p.Scale, p.DesiredScale) + + if p.MaxScale > 0 { + fmt.Printf("Max Scale: %d\n", p.MaxScale) + } + + restarts := 0 + for _, inst := range p.Instances { + restarts += inst.RestartCount + } + fmt.Printf("Restarts: %d\n", restarts) + + if len(p.Instances) > 0 { + for _, inst := range p.Instances { + var uptime string + if inst.StartedAt > 0 { + d := time.Since(time.Unix(inst.StartedAt, 0)) + uptime = formatDuration(d) + } else { + uptime = "-" + } + fmt.Printf("Instance: %s (pid=%d, state=%s, uptime=%s, restarts=%d)\n", + inst.ID, inst.PID, inst.State, uptime, inst.RestartCount) + } + } + + if p.CPUPercent > 0 || p.MemoryRSSBytes > 0 { + fmt.Printf("CPU: %.1f%%\n", p.CPUPercent) + fmt.Printf("Memory: %s\n", formatBytes(p.MemoryRSSBytes)) + } + + if p.Schedule != "" { + fmt.Printf("Schedule: %s\n", p.Schedule) + fmt.Printf("Sched State:%s\n", p.ScheduleState) + if p.NextRun > 0 { + fmt.Printf("Next Run: %s\n", time.Unix(p.NextRun, 0).Format(time.RFC3339)) + } + } + break + } + + if !found { + fmt.Fprintf(os.Stderr, "āŒ Process not found: %s\n", processName) + os.Exit(1) + } +} + +func formatBytes(b uint64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case b >= GB: + return fmt.Sprintf("%.1fG", float64(b)/float64(GB)) + case b >= MB: + return fmt.Sprintf("%.1fM", float64(b)/float64(MB)) + case b >= KB: + return fmt.Sprintf("%.1fK", float64(b)/float64(KB)) + default: + return fmt.Sprintf("%dB", b) + } +} From e6e595c437786df15beca9dc8906aad8d32b2ab0 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:42:13 +0200 Subject: [PATCH 04/18] feat: add start, stop, restart, scale, reload-config CLI commands Add process control commands that connect to the running daemon via API: - `cbox-init start ` / `stop ` / `restart ` - `cbox-init scale ` with input validation - `cbox-init reload-config` to hot-reload configuration All commands share the newClient() helper from list.go, supporting --url flag and CBOX_INIT_API_AUTH env var. Also updates root.go to register all new commands and refresh the help examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/cbox-init/reload_config.go | 31 +++++++++++++++++++++++++ cmd/cbox-init/restart.go | 31 +++++++++++++++++++++++++ cmd/cbox-init/root.go | 20 +++++++++------- cmd/cbox-init/scale.go | 42 ++++++++++++++++++++++++++++++++++ cmd/cbox-init/start_cmd.go | 31 +++++++++++++++++++++++++ cmd/cbox-init/stop_cmd.go | 31 +++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 cmd/cbox-init/reload_config.go create mode 100644 cmd/cbox-init/restart.go create mode 100644 cmd/cbox-init/scale.go create mode 100644 cmd/cbox-init/start_cmd.go create mode 100644 cmd/cbox-init/stop_cmd.go diff --git a/cmd/cbox-init/reload_config.go b/cmd/cbox-init/reload_config.go new file mode 100644 index 0000000..1b5b43c --- /dev/null +++ b/cmd/cbox-init/reload_config.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var reloadConfigCmd = &cobra.Command{ + Use: "reload-config", + Short: "Reload configuration from disk", + Long: `Reload the configuration file from disk without restarting the daemon.`, + Args: cobra.NoArgs, + Run: runReloadConfig, +} + +var reloadConfigURL string + +func init() { + reloadConfigCmd.Flags().StringVar(&reloadConfigURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runReloadConfig(cmd *cobra.Command, args []string) { + client := newClient(reloadConfigURL) + if err := client.ReloadConfig(); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to reload config: %v\n", err) + os.Exit(1) + } + fmt.Println("āœ“ Configuration reloaded") +} diff --git a/cmd/cbox-init/restart.go b/cmd/cbox-init/restart.go new file mode 100644 index 0000000..342f147 --- /dev/null +++ b/cmd/cbox-init/restart.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var restartCmd = &cobra.Command{ + Use: "restart ", + Short: "Restart a process", + Args: cobra.ExactArgs(1), + Run: runRestart, +} + +var restartURL string + +func init() { + restartCmd.Flags().StringVar(&restartURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runRestart(cmd *cobra.Command, args []string) { + name := args[0] + client := newClient(restartURL) + if err := client.RestartProcess(name); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to restart %s: %v\n", name, err) + os.Exit(1) + } + fmt.Printf("āœ“ %s restarted\n", name) +} diff --git a/cmd/cbox-init/root.go b/cmd/cbox-init/root.go index 7cafc8f..20d1c08 100644 --- a/cmd/cbox-init/root.go +++ b/cmd/cbox-init/root.go @@ -31,9 +31,11 @@ A modern process supervisor designed for Laravel and PHP applications with: Examples: cbox-init serve # Start daemon cbox-init tui # Interactive dashboard - cbox-init logs nginx # Tail nginx logs + cbox-init list # List all processes + cbox-init status nginx # Show process details cbox-init restart horizon # Restart horizon - cbox-init scale queue-default 10 # Scale to 10 workers`, + cbox-init scale queue-default 10 # Scale to 10 workers + cbox-init logs nginx -f # Stream nginx logs`, Version: version, // Default to serve command if no subcommand specified Run: func(cmd *cobra.Command, args []string) { @@ -61,10 +63,12 @@ func init() { rootCmd.AddCommand(tuiCmd) rootCmd.AddCommand(logsCmd) rootCmd.AddCommand(scaffoldCmd) - // Process control commands (future): - // rootCmd.AddCommand(restartCmd) - // rootCmd.AddCommand(stopCmd) - // rootCmd.AddCommand(startCmd) - // rootCmd.AddCommand(scaleCmd) - // rootCmd.AddCommand(statusCmd) + // Process control commands + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(startProcessCmd) + rootCmd.AddCommand(stopProcessCmd) + rootCmd.AddCommand(restartCmd) + rootCmd.AddCommand(scaleCmd) + rootCmd.AddCommand(reloadConfigCmd) } diff --git a/cmd/cbox-init/scale.go b/cmd/cbox-init/scale.go new file mode 100644 index 0000000..06b0f09 --- /dev/null +++ b/cmd/cbox-init/scale.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" +) + +var scaleCmd = &cobra.Command{ + Use: "scale ", + Short: "Scale a process to the specified number of instances", + Long: `Scale a process to the specified number of instances. + +Examples: + cbox-init scale queue-default 10 # Scale to 10 workers + cbox-init scale horizon 1 # Scale back to 1`, + Args: cobra.ExactArgs(2), + Run: runScale, +} + +var scaleURL string + +func init() { + scaleCmd.Flags().StringVar(&scaleURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runScale(cmd *cobra.Command, args []string) { + name := args[0] + count, err := strconv.Atoi(args[1]) + if err != nil || count < 0 { + fmt.Fprintf(os.Stderr, "āŒ Invalid scale count: %s (must be a non-negative integer)\n", args[1]) + os.Exit(1) + } + client := newClient(scaleURL) + if err := client.ScaleProcess(name, count); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to scale %s: %v\n", name, err) + os.Exit(1) + } + fmt.Printf("āœ“ %s scaled to %d instances\n", name, count) +} diff --git a/cmd/cbox-init/start_cmd.go b/cmd/cbox-init/start_cmd.go new file mode 100644 index 0000000..ef350c7 --- /dev/null +++ b/cmd/cbox-init/start_cmd.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var startProcessCmd = &cobra.Command{ + Use: "start ", + Short: "Start a stopped process", + Args: cobra.ExactArgs(1), + Run: runStartProcess, +} + +var startURL string + +func init() { + startProcessCmd.Flags().StringVar(&startURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runStartProcess(cmd *cobra.Command, args []string) { + name := args[0] + client := newClient(startURL) + if err := client.StartProcess(name); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to start %s: %v\n", name, err) + os.Exit(1) + } + fmt.Printf("āœ“ %s started\n", name) +} diff --git a/cmd/cbox-init/stop_cmd.go b/cmd/cbox-init/stop_cmd.go new file mode 100644 index 0000000..c802b25 --- /dev/null +++ b/cmd/cbox-init/stop_cmd.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var stopProcessCmd = &cobra.Command{ + Use: "stop ", + Short: "Stop a running process", + Args: cobra.ExactArgs(1), + Run: runStopProcess, +} + +var stopURL string + +func init() { + stopProcessCmd.Flags().StringVar(&stopURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runStopProcess(cmd *cobra.Command, args []string) { + name := args[0] + client := newClient(stopURL) + if err := client.StopProcess(name); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to stop %s: %v\n", name, err) + os.Exit(1) + } + fmt.Printf("āœ“ %s stopped\n", name) +} From cd401de45de4390a157ae602b4d4561ff435a68f Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:50:13 +0200 Subject: [PATCH 05/18] feat: add log broadcaster for real-time SSE subscription Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/logger/log_buffer.go | 19 +++-- internal/logger/process_writer.go | 7 ++ internal/logger/subscriber.go | 69 ++++++++++++++++++ internal/logger/subscriber_test.go | 110 +++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 internal/logger/subscriber.go create mode 100644 internal/logger/subscriber_test.go diff --git a/internal/logger/log_buffer.go b/internal/logger/log_buffer.go index d366e35..6ee41e1 100644 --- a/internal/logger/log_buffer.go +++ b/internal/logger/log_buffer.go @@ -21,7 +21,8 @@ type LogBuffer struct { entries []LogEntry size int index int - full bool + full bool + broadcaster *LogBroadcaster // optional, for real-time subscribers } // NewLogBuffer creates a new log buffer with the specified capacity @@ -40,15 +41,18 @@ func NewLogBuffer(size int) *LogBuffer { // Add adds a log entry to the buffer func (lb *LogBuffer) Add(entry LogEntry) { lb.mu.Lock() - defer lb.mu.Unlock() - lb.entries[lb.index] = entry lb.index++ - if lb.index >= lb.size { lb.index = 0 lb.full = true } + b := lb.broadcaster + lb.mu.Unlock() + + if b != nil { + b.Broadcast(entry) + } } // GetAll returns all log entries in chronological order @@ -135,3 +139,10 @@ func (lb *LogBuffer) Size() int { } return lb.index } + +// SetBroadcaster sets the broadcaster for real-time log subscriptions. +func (lb *LogBuffer) SetBroadcaster(b *LogBroadcaster) { + lb.mu.Lock() + defer lb.mu.Unlock() + lb.broadcaster = b +} diff --git a/internal/logger/process_writer.go b/internal/logger/process_writer.go index bea866e..7c29bca 100644 --- a/internal/logger/process_writer.go +++ b/internal/logger/process_writer.go @@ -251,6 +251,13 @@ func (pw *ProcessWriter) Flush() { } } +// SetBroadcaster sets the broadcaster for real-time log subscriptions. +func (pw *ProcessWriter) SetBroadcaster(b *LogBroadcaster) { + if pw.logBuffer != nil { + pw.logBuffer.SetBroadcaster(b) + } +} + // GetLogs returns all log entries from the buffer func (pw *ProcessWriter) GetLogs() []LogEntry { if pw.logBuffer == nil { diff --git a/internal/logger/subscriber.go b/internal/logger/subscriber.go new file mode 100644 index 0000000..c51a84e --- /dev/null +++ b/internal/logger/subscriber.go @@ -0,0 +1,69 @@ +package logger + +import ( + "sync" +) + +// LogBroadcaster delivers log entries to multiple subscribers in real-time. +// Subscribers receive entries on a buffered channel. If a subscriber can't +// keep up, entries are dropped (non-blocking send). +type LogBroadcaster struct { + mu sync.RWMutex + subscribers map[uint64]*subscription + nextID uint64 +} + +type subscription struct { + ch chan LogEntry + filter string // process name filter, empty = all +} + +// NewLogBroadcaster creates a new broadcaster. +func NewLogBroadcaster() *LogBroadcaster { + return &LogBroadcaster{ + subscribers: make(map[uint64]*subscription), + } +} + +// Subscribe registers a new subscriber. +// filter is a process name — empty string receives all processes. +// Returns a read channel and an unsubscribe function. +// The channel is closed when unsubscribe is called. +func (b *LogBroadcaster) Subscribe(filter string) (<-chan LogEntry, func()) { + b.mu.Lock() + defer b.mu.Unlock() + + id := b.nextID + b.nextID++ + + ch := make(chan LogEntry, 256) + b.subscribers[id] = &subscription{ch: ch, filter: filter} + + unsub := func() { + b.mu.Lock() + defer b.mu.Unlock() + if sub, ok := b.subscribers[id]; ok { + close(sub.ch) + delete(b.subscribers, id) + } + } + + return ch, unsub +} + +// Broadcast sends a log entry to all matching subscribers. +// Non-blocking: if a subscriber's buffer is full, the entry is dropped. +func (b *LogBroadcaster) Broadcast(entry LogEntry) { + b.mu.RLock() + defer b.mu.RUnlock() + + for _, sub := range b.subscribers { + if sub.filter != "" && sub.filter != entry.ProcessName { + continue + } + select { + case sub.ch <- entry: + default: + } + } +} diff --git a/internal/logger/subscriber_test.go b/internal/logger/subscriber_test.go new file mode 100644 index 0000000..53959ce --- /dev/null +++ b/internal/logger/subscriber_test.go @@ -0,0 +1,110 @@ +package logger + +import ( + "testing" + "time" +) + +func TestLogBroadcaster_SubscribeReceivesEntries(t *testing.T) { + b := NewLogBroadcaster() + ch, unsub := b.Subscribe("") + defer unsub() + + entry := LogEntry{ + Timestamp: time.Now(), + ProcessName: "nginx", + InstanceID: "nginx-0", + Stream: "stdout", + Message: "hello", + Level: "info", + } + b.Broadcast(entry) + + select { + case got := <-ch: + if got.Message != "hello" || got.ProcessName != "nginx" { + t.Errorf("unexpected entry: %+v", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for entry") + } +} + +func TestLogBroadcaster_FilterByProcess(t *testing.T) { + b := NewLogBroadcaster() + ch, unsub := b.Subscribe("nginx") + defer unsub() + + b.Broadcast(LogEntry{ProcessName: "php-fpm", Message: "wrong"}) + b.Broadcast(LogEntry{ProcessName: "nginx", Message: "correct"}) + + select { + case got := <-ch: + if got.Message != "correct" { + t.Errorf("expected 'correct', got %q", got.Message) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for filtered entry") + } +} + +func TestLogBroadcaster_UnsubscribeStopsDelivery(t *testing.T) { + b := NewLogBroadcaster() + ch, unsub := b.Subscribe("") + unsub() + + b.Broadcast(LogEntry{Message: "after-unsub"}) + + select { + case _, ok := <-ch: + if ok { + t.Error("received entry after unsubscribe") + } + case <-time.After(100 * time.Millisecond): + } +} + +func TestLogBroadcaster_MultipleSubscribers(t *testing.T) { + b := NewLogBroadcaster() + ch1, unsub1 := b.Subscribe("") + defer unsub1() + ch2, unsub2 := b.Subscribe("") + defer unsub2() + + b.Broadcast(LogEntry{Message: "both"}) + + for _, ch := range []<-chan LogEntry{ch1, ch2} { + select { + case got := <-ch: + if got.Message != "both" { + t.Errorf("unexpected: %q", got.Message) + } + case <-time.After(time.Second): + t.Fatal("timed out") + } + } +} + +func TestLogBroadcaster_SlowSubscriberDropsEntries(t *testing.T) { + b := NewLogBroadcaster() + ch, unsub := b.Subscribe("") + defer unsub() + + for i := 0; i < 300; i++ { + b.Broadcast(LogEntry{Message: "flood"}) + } + + count := 0 + for { + select { + case <-ch: + count++ + default: + goto done + } + } +done: + if count > 256 { + t.Errorf("got %d entries, expected at most 256", count) + } +} From 96177e9abed7efaa829c5e9d3767a19af053c93d Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:50:21 +0200 Subject: [PATCH 06/18] feat: wire log broadcaster through process manager to all supervisors Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/process/manager.go | 10 +++++++++- internal/process/manager_config.go | 6 ++++++ internal/process/manager_lifecycle.go | 1 + internal/process/manager_logs.go | 7 +++++++ internal/process/supervisor.go | 18 ++++++++++++++++++ 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/process/manager.go b/internal/process/manager.go index cce0ae5..1970b7c 100644 --- a/internal/process/manager.go +++ b/internal/process/manager.go @@ -7,6 +7,7 @@ import ( "github.com/cboxdk/init/internal/audit" "github.com/cboxdk/init/internal/config" + logpkg "github.com/cboxdk/init/internal/logger" "github.com/cboxdk/init/internal/metrics" "github.com/cboxdk/init/internal/readiness" "github.com/cboxdk/init/internal/schedule" @@ -60,7 +61,8 @@ type Manager struct { allDeadCh chan struct{} allDeadOnce sync.Once // Ensures allDeadCh is closed only once processDeathCh chan string - startTime time.Time + startTime time.Time + logBroadcaster *logpkg.LogBroadcaster // Configurable timeouts and limits (initialized from global config or defaults) dependencyTimeout time.Duration @@ -144,6 +146,7 @@ func NewManager(cfg *config.Config, logger *slog.Logger, auditLogger *audit.Logg allDeadCh: make(chan struct{}), processDeathCh: make(chan string, 10), startTime: startTime, + logBroadcaster: logpkg.NewLogBroadcaster(), dependencyTimeout: dependencyTimeout, processStopTimeout: processStopTimeout, maxProcessScale: maxProcessScale, @@ -308,3 +311,8 @@ func (m *Manager) ListProcesses() []ProcessInfo { func (m *Manager) GetResourceCollector() *metrics.ResourceCollector { return m.resourceCollector } + +// LogBroadcaster returns the log broadcaster for real-time log subscriptions. +func (m *Manager) LogBroadcaster() *logpkg.LogBroadcaster { + return m.logBroadcaster +} diff --git a/internal/process/manager_config.go b/internal/process/manager_config.go index fa87905..4e18eff 100644 --- a/internal/process/manager_config.go +++ b/internal/process/manager_config.go @@ -47,6 +47,7 @@ func (m *Manager) AddProcess(ctx context.Context, name string, procCfg *config.P supervisor := NewSupervisor(name, procCfg, &m.config.Global, m.logger, m.auditLogger, m.resourceCollector) supervisor.SetOneshotHistory(m.oneshotHistory) + supervisor.SetLogBroadcaster(m.logBroadcaster) // Use background context for supervisor lifetime (independent of API request) if err := supervisor.Start(context.Background()); err != nil { // Remove from config on failure @@ -148,6 +149,7 @@ func (m *Manager) updateProcessLocked(ctx context.Context, name string, procCfg if procCfg.Enabled { newSupervisor := NewSupervisor(name, procCfg, &m.config.Global, m.logger, m.auditLogger, m.resourceCollector) newSupervisor.SetOneshotHistory(m.oneshotHistory) + newSupervisor.SetLogBroadcaster(m.logBroadcaster) // Use background context for supervisor lifetime (independent of API request) if err := newSupervisor.Start(context.Background()); err != nil { // Rollback config change on error @@ -168,6 +170,7 @@ func (m *Manager) updateProcessLocked(ctx context.Context, name string, procCfg supervisor := NewSupervisor(name, procCfg, &m.config.Global, m.logger, m.auditLogger, m.resourceCollector) supervisor.SetOneshotHistory(m.oneshotHistory) + supervisor.SetLogBroadcaster(m.logBroadcaster) // Use background context for supervisor lifetime (independent of API request) if err := supervisor.Start(context.Background()); err != nil { // Rollback config change on error @@ -322,6 +325,7 @@ func (m *Manager) startNewProcesses(cfg *config.Config, names []string) { m.logger.Info("Starting new process", "name", name) supervisor := NewSupervisor(name, procCfg, &cfg.Global, m.logger, m.auditLogger, m.resourceCollector) supervisor.SetOneshotHistory(m.oneshotHistory) + supervisor.SetLogBroadcaster(m.logBroadcaster) // Use background context for supervisor lifetime (independent of reload request) if err := supervisor.Start(context.Background()); err != nil { m.logger.Error("Failed to start new process during reload", "name", name, "error", err) @@ -348,6 +352,7 @@ func (m *Manager) updateChangedProcesses(ctx context.Context, cfg *config.Config if procCfg.Enabled { newSupervisor := NewSupervisor(name, procCfg, &cfg.Global, m.logger, m.auditLogger, m.resourceCollector) newSupervisor.SetOneshotHistory(m.oneshotHistory) + newSupervisor.SetLogBroadcaster(m.logBroadcaster) // Use background context for supervisor lifetime (independent of reload request) if err := newSupervisor.Start(context.Background()); err != nil { m.logger.Error("Failed to start updated process", "name", name, "error", err) @@ -361,6 +366,7 @@ func (m *Manager) updateChangedProcesses(ctx context.Context, cfg *config.Config m.logger.Info("Starting previously disabled process", "name", name) supervisor := NewSupervisor(name, procCfg, &cfg.Global, m.logger, m.auditLogger, m.resourceCollector) supervisor.SetOneshotHistory(m.oneshotHistory) + supervisor.SetLogBroadcaster(m.logBroadcaster) // Use background context for supervisor lifetime (independent of reload request) if err := supervisor.Start(context.Background()); err != nil { m.logger.Error("Failed to start process during reload", "name", name, "error", err) diff --git a/internal/process/manager_lifecycle.go b/internal/process/manager_lifecycle.go index ff86dcf..42b78af 100644 --- a/internal/process/manager_lifecycle.go +++ b/internal/process/manager_lifecycle.go @@ -215,6 +215,7 @@ func (m *Manager) startRegularProcess(ctx context.Context, name string, procCfg sup := NewSupervisor(name, procCfg, &m.config.Global, m.logger, m.auditLogger, m.resourceCollector) sup.SetDeathNotifier(m.NotifyProcessDeath) sup.SetOneshotHistory(m.oneshotHistory) + sup.SetLogBroadcaster(m.logBroadcaster) m.processes[name] = sup // Start the process only if initial_state is "running" diff --git a/internal/process/manager_logs.go b/internal/process/manager_logs.go index be590bf..ceecf75 100644 --- a/internal/process/manager_logs.go +++ b/internal/process/manager_logs.go @@ -28,6 +28,13 @@ func (m *Manager) GetLogs(processName string, limit int) ([]logger.LogEntry, err return nil, fmt.Errorf("process not found: %s", processName) } +// SubscribeLogs registers a real-time log subscriber. +// filter is a process name — empty string receives all processes. +// Returns a read channel and an unsubscribe function. +func (m *Manager) SubscribeLogs(filter string) (<-chan logger.LogEntry, func()) { + return m.logBroadcaster.Subscribe(filter) +} + // GetStackLogs aggregates logs from all processes in the manager. // Returns the most recent entries across the stack capped by limit (if > 0). func (m *Manager) GetStackLogs(limit int) []logger.LogEntry { diff --git a/internal/process/supervisor.go b/internal/process/supervisor.go index fe71d7a..5d28762 100644 --- a/internal/process/supervisor.go +++ b/internal/process/supervisor.go @@ -105,6 +105,7 @@ type Supervisor struct { oneshotHistory *OneshotHistory // Shared oneshot history (can be nil) deathNotifier func(string) // Callback when all instances are dead credentials *Credentials // Resolved user/group credentials (nil = inherit) + logBroadcaster *logger.LogBroadcaster // Shared broadcaster for real-time log subscriptions healthCheckStrict bool // Fail startup if health monitor creation fails ctx context.Context cancel context.CancelFunc @@ -235,6 +236,13 @@ func (s *Supervisor) SetOneshotHistory(history *OneshotHistory) { s.oneshotHistory = history } +// SetLogBroadcaster sets the shared log broadcaster for real-time log subscriptions +func (s *Supervisor) SetLogBroadcaster(b *logger.LogBroadcaster) { + s.mu.Lock() + defer s.mu.Unlock() + s.logBroadcaster = b +} + // streamEnabled determines if stdout/stderr streaming is enabled for this process func (s *Supervisor) streamEnabled(stream string) bool { if s.config.Logging == nil { @@ -442,6 +450,16 @@ func (s *Supervisor) startInstance(ctx context.Context, instanceID string, insta } } + // Wire log broadcaster to process writers for real-time subscriptions + if s.logBroadcaster != nil { + if stdoutWriter != nil { + stdoutWriter.SetBroadcaster(s.logBroadcaster) + } + if stderrWriter != nil { + stderrWriter.SetBroadcaster(s.logBroadcaster) + } + } + if stdoutWriter != nil { cmd.Stdout = stdoutWriter } else { From 1ceca6d466e2425c99ba42d49ff0aca0c9d214f5 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:54:44 +0200 Subject: [PATCH 07/18] feat: add SSE log stream endpoint at /api/v1/logs/stream Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/server.go | 66 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/internal/api/server.go b/internal/api/server.go index fdc1087..8397a52 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -260,6 +260,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/api/v1/processes", s.wrapHandler(s.handleProcesses, true)) mux.HandleFunc("/api/v1/processes/", s.wrapHandler(s.handleProcessAction, true)) mux.HandleFunc("/api/v1/logs", s.wrapHandler(s.handleStackLogs, true)) + mux.HandleFunc("/api/v1/logs/stream", s.wrapHandler(s.handleLogStream, true)) // Config management endpoints mux.HandleFunc("/api/v1/config/save", s.wrapHandler(s.handleConfigSave, true)) mux.HandleFunc("/api/v1/config/reload", s.wrapHandler(s.handleConfigReload, true)) @@ -355,6 +356,7 @@ func (s *Server) StartSocketOnly(ctx context.Context) error { mux.HandleFunc("/api/v1/processes", s.wrapHandler(s.handleProcesses, false)) mux.HandleFunc("/api/v1/processes/", s.wrapHandler(s.handleProcessAction, false)) mux.HandleFunc("/api/v1/logs", s.wrapHandler(s.handleStackLogs, false)) + mux.HandleFunc("/api/v1/logs/stream", s.wrapHandler(s.handleLogStream, false)) mux.HandleFunc("/api/v1/config/save", s.wrapHandler(s.handleConfigSave, false)) mux.HandleFunc("/api/v1/config/reload", s.wrapHandler(s.handleConfigReload, false)) mux.HandleFunc("/api/v1/metrics/history", s.wrapHandler(s.handleMetricsHistory, false)) @@ -965,6 +967,70 @@ func (s *Server) handleGetProcess(w http.ResponseWriter, _ *http.Request, proces }) } +// handleLogStream provides a Server-Sent Events stream of real-time log entries. +// Query params: +// - process: filter by process name (optional, empty = all) +func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + processFilter := r.URL.Query().Get("process") + + ch, unsub := s.manager.SubscribeLogs(processFilter) + defer unsub() + + // Disable write deadline for this SSE connection + rc := http.NewResponseController(w) + if err := rc.SetWriteDeadline(time.Time{}); err != nil { + s.logger.Warn("Failed to disable write deadline for SSE", "error", err) + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + flusher.Flush() + + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-r.Context().Done(): + return + case entry, ok := <-ch: + if !ok { + return + } + data, err := json.Marshal(map[string]interface{}{ + "timestamp": entry.Timestamp.Format(time.RFC3339Nano), + "process": entry.ProcessName, + "instance": entry.InstanceID, + "stream": entry.Stream, + "level": entry.Level, + "message": entry.Message, + }) + if err != nil { + s.logger.Error("Failed to marshal log entry", "error", err) + continue + } + fmt.Fprintf(w, "event: log\ndata: %s\n\n", data) + flusher.Flush() + case <-heartbeat.C: + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} + // handleStackLogs aggregates logs across the entire stack func (s *Server) handleStackLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 0880698cf77c25ae5592c25e36982706ddab8926 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:54:48 +0200 Subject: [PATCH 08/18] feat: add SSE log stream client for cbox-init logs -f Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/apiclient/stream.go | 97 +++++++++++++++++++++++++ internal/apiclient/stream_test.go | 114 ++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 internal/apiclient/stream.go create mode 100644 internal/apiclient/stream_test.go diff --git a/internal/apiclient/stream.go b/internal/apiclient/stream.go new file mode 100644 index 0000000..d915bae --- /dev/null +++ b/internal/apiclient/stream.go @@ -0,0 +1,97 @@ +package apiclient + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/cboxdk/init/internal/logger" +) + +// StreamLogs connects to the SSE log stream and returns a channel of log entries. +// The channel is closed when the context is cancelled or the connection drops. +// If process is non-empty, only logs from that process are streamed. +func (c *Client) StreamLogs(ctx context.Context, process string) (<-chan logger.LogEntry, error) { + path := "/api/v1/logs/stream" + if process != "" { + path = fmt.Sprintf("%s?process=%s", path, process) + } + + req, err := http.NewRequestWithContext(ctx, "GET", c.getURL(path), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "text/event-stream") + if c.auth != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth)) + } + + // Use a client without timeout for streaming + streamClient := *c.client + streamClient.Timeout = 0 + + resp, err := streamClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to connect to log stream: %w", err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("log stream returned status %d", resp.StatusCode) + } + + ch := make(chan logger.LogEntry, 256) + + go func() { + defer close(ch) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + var entry struct { + Timestamp string `json:"timestamp"` + Process string `json:"process"` + Instance string `json:"instance"` + Stream string `json:"stream"` + Level string `json:"level"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(data), &entry); err != nil { + continue + } + + ts, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + + logEntry := logger.LogEntry{ + Timestamp: ts, + ProcessName: entry.Process, + InstanceID: entry.Instance, + Stream: entry.Stream, + Level: entry.Level, + Message: entry.Message, + } + + select { + case ch <- logEntry: + case <-ctx.Done(): + return + } + } + } + }() + + return ch, nil +} diff --git a/internal/apiclient/stream_test.go b/internal/apiclient/stream_test.go new file mode 100644 index 0000000..b871e79 --- /dev/null +++ b/internal/apiclient/stream_test.go @@ -0,0 +1,114 @@ +package apiclient + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestStreamLogs_ReceivesEntries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/logs/stream" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not a flusher") + } + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, "event: log\ndata: {\"timestamp\":\"2026-04-17T10:00:00Z\",\"process\":\"nginx\",\"level\":\"info\",\"message\":\"hello\"}\n\n") + flusher.Flush() + + fmt.Fprintf(w, "event: log\ndata: {\"timestamp\":\"2026-04-17T10:00:01Z\",\"process\":\"nginx\",\"level\":\"info\",\"message\":\"world\"}\n\n") + flusher.Flush() + + <-r.Context().Done() + })) + defer server.Close() + + client := New(server.URL, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch, err := client.StreamLogs(ctx, "") + if err != nil { + t.Fatalf("StreamLogs error: %v", err) + } + + entry1 := <-ch + if entry1.Message != "hello" || entry1.ProcessName != "nginx" { + t.Errorf("unexpected first entry: %+v", entry1) + } + + entry2 := <-ch + if entry2.Message != "world" { + t.Errorf("unexpected second entry: %+v", entry2) + } +} + +func TestStreamLogs_WithProcessFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + process := r.URL.Query().Get("process") + if process != "nginx" { + t.Errorf("expected process=nginx, got %q", process) + } + + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: log\ndata: {\"process\":\"nginx\",\"message\":\"filtered\"}\n\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer server.Close() + + client := New(server.URL, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch, err := client.StreamLogs(ctx, "nginx") + if err != nil { + t.Fatalf("StreamLogs error: %v", err) + } + + entry := <-ch + if entry.Message != "filtered" { + t.Errorf("unexpected: %+v", entry) + } +} + +func TestStreamLogs_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer server.Close() + + client := New(server.URL, "") + ctx, cancel := context.WithCancel(context.Background()) + + ch, err := client.StreamLogs(ctx, "") + if err != nil { + t.Fatalf("StreamLogs error: %v", err) + } + + cancel() + + select { + case _, ok := <-ch: + if ok { + // Might get a zero value, that's fine + } + case <-time.After(2 * time.Second): + t.Fatal("channel not closed after context cancel") + } +} From fbaf88e5e29fdd5a44c68a8520964eaf5f36bc93 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:58:30 +0200 Subject: [PATCH 09/18] feat: rewrite logs command to use API client with SSE streaming Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/cbox-init/logs.go | 128 ++++++++++++++++-------------------------- 1 file changed, 49 insertions(+), 79 deletions(-) diff --git a/cmd/cbox-init/logs.go b/cmd/cbox-init/logs.go index 9f8936e..26ede90 100644 --- a/cmd/cbox-init/logs.go +++ b/cmd/cbox-init/logs.go @@ -3,120 +3,90 @@ package main import ( "context" "fmt" - "log/slog" "os" - "time" + "os/signal" - "github.com/cboxdk/init/internal/audit" - "github.com/cboxdk/init/internal/config" "github.com/cboxdk/init/internal/logger" - "github.com/cboxdk/init/internal/process" - "github.com/cboxdk/init/internal/setup" - "github.com/cboxdk/init/internal/signals" - "github.com/cboxdk/init/internal/tui" "github.com/spf13/cobra" ) var logsCmd = &cobra.Command{ - Use: "logs [process...]", - Short: "Tail logs from processes", - Long: `Tail logs from one or more processes in real-time. - -If no process names are specified, shows logs from all processes. + Use: "logs [process]", + Short: "Tail logs from a process or all processes", + Long: `Tail logs from one or all processes via the daemon API. Examples: - cbox-init logs # All processes - cbox-init logs nginx # Single process - cbox-init logs nginx horizon # Multiple processes - cbox-init logs --level=error # Filter by level - cbox-init logs --tail=100 # Last 100 lines`, - Run: runLogs, + cbox-init logs # All processes, last 100 lines + cbox-init logs nginx # Specific process + cbox-init logs nginx --tail 50 # Last 50 lines + cbox-init logs -f # Stream all processes + cbox-init logs nginx -f # Stream specific process + cbox-init logs nginx --tail 20 -f # Last 20 lines then stream`, + Args: cobra.MaximumNArgs(1), + Run: runLogs, } var ( - logsLevel string logsTail int logsFollow bool + logsLevel string + logsURL string ) func init() { - logsCmd.Flags().StringVar(&logsLevel, "level", "all", "Filter by log level (debug|info|warn|error|all)") logsCmd.Flags().IntVar(&logsTail, "tail", 100, "Number of lines to show") - logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", true, "Follow log output") + logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Stream new log entries") + logsCmd.Flags().StringVar(&logsLevel, "level", "all", "Filter by log level (debug|info|warn|error|all)") + logsCmd.Flags().StringVar(&logsURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") } func runLogs(cmd *cobra.Command, args []string) { - // Get config path - cfgPath := getConfigPath() - - // Setup environment (minimal for log viewer) - workdir := os.Getenv("WORKDIR") - if workdir == "" { - workdir = "/var/www/html" + var processName string + if len(args) > 0 { + processName = args[0] } - // Setup permissions (silent, detects framework internally) - permMgr := setup.NewPermissionManager(workdir, slog.Default()) - _ = permMgr.Setup() - - // Validate system (silent) - validator := setup.NewConfigValidator(slog.Default()) - _ = validator.ValidateAll() + client := newClient(logsURL) - // Load configuration - cfg, err := config.LoadWithEnvExpansion(cfgPath) + // Fetch historical logs + var logs []logger.LogEntry + var err error + if processName != "" { + logs, err = client.GetLogs(processName, logsTail) + } else { + logs, err = client.GetStackLogs(logsTail) + } if err != nil { - fmt.Fprintf(os.Stderr, "āŒ Failed to load configuration: %v\n", err) + fmt.Fprintf(os.Stderr, "Failed to fetch logs: %v\n", err) os.Exit(1) } - // Initialize logger with specified level - logLevel := cfg.Global.LogLevel - if logsLevel != "all" { - logLevel = logsLevel + // Print historical logs + for _, entry := range logs { + printLogEntry(entry) } - log := logger.New(logLevel, "text") // Text format for readability - - slog.SetDefault(log) - - // Create audit logger - auditLogger := audit.NewLogger(log, cfg.Global.AuditEnabled) - // Create process manager - pm := process.NewManager(cfg, log, auditLogger) - - // Start zombie reaper - go signals.ReapZombies(cfg.Global.ZombieReapInterval) - - // Start processes - ctx := context.Background() - if err := pm.Start(ctx); err != nil { - fmt.Fprintf(os.Stderr, "āŒ Failed to start processes: %v\n", err) - os.Exit(1) + // If not following, we're done + if !logsFollow { + return } - // Monitor process health - pm.MonitorProcessHealth(ctx) - - // Display header - fmt.Fprintf(os.Stderr, "šŸ“‹ Tailing logs") - if len(args) > 0 { - fmt.Fprintf(os.Stderr, " for: %v", args) - } else { - fmt.Fprintf(os.Stderr, " for all processes") - } - fmt.Fprintf(os.Stderr, " (level: %s)\n", logLevel) - fmt.Fprintf(os.Stderr, "Press Ctrl+C to exit\n\n") + // Stream new entries via SSE + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() - // Launch simple log viewer - if err := tui.RunLogs(pm); err != nil { - fmt.Fprintf(os.Stderr, "āŒ Error: %v\n", err) + ch, err := client.StreamLogs(ctx, processName) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to connect to log stream: %v\n", err) os.Exit(1) } - // Shutdown when viewer exits - shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Global.ShutdownTimeout)*time.Second) - defer cancel() + for entry := range ch { + printLogEntry(entry) + } +} - _ = pm.Shutdown(shutdownCtx) +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) } From 38f5b791ab0b034580603597052ed62a360a98f6 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 12:58:35 +0200 Subject: [PATCH 10/18] fix: update logs follow flag default test to match new behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/cbox-init/cmd_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/cbox-init/cmd_test.go b/cmd/cbox-init/cmd_test.go index 8c988ab..d1e2267 100644 --- a/cmd/cbox-init/cmd_test.go +++ b/cmd/cbox-init/cmd_test.go @@ -1405,7 +1405,7 @@ func TestLogsCommandFlagDefaults(t *testing.T) { }{ {"level", "all"}, {"tail", "100"}, - {"follow", "true"}, + {"follow", "false"}, } for _, tt := range tests { From eb94ee71bd3d98a73d1a2868054b183866c1e45f Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 15:34:29 +0200 Subject: [PATCH 11/18] feat: add log file tailing config structs (LogFileConfig, RotateConfig) Add LogFileConfig and RotateConfig structs to support per-process log file tailing configuration. Each log file gets its own pipeline config for level detection, multiline handling, JSON parsing, and filtering. Also adds ParseSize helper for human-readable size strings and a Files map field to LoggingConfig. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/config/types.go | 80 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/internal/config/types.go b/internal/config/types.go index 400b4e8..392b825 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,6 +1,11 @@ package config -import "time" +import ( + "fmt" + "strconv" + "strings" + "time" +) // Config represents the complete cbox-init configuration type Config struct { @@ -147,6 +152,7 @@ type LoggingConfig struct { JSON *JSONConfig `yaml:"json" json:"json"` // JSON log parsing LevelDetection *LevelDetectionConfig `yaml:"level_detection" json:"level_detection"` // Log level detection from content Filters *FilterConfig `yaml:"filters" json:"filters"` // Include/exclude filtering + Files map[string]*LogFileConfig `yaml:"files" json:"files"` // Log file tailing } // RedactionConfig configures sensitive data redaction for compliance @@ -192,6 +198,60 @@ type FilterConfig struct { Include []string `yaml:"include" json:"include"` // Only include logs matching these patterns (if specified) } +// LogFileConfig configures tailing of a local log file +type LogFileConfig struct { + Path string `yaml:"path" json:"path"` + Rotate *RotateConfig `yaml:"rotate" json:"rotate"` + MinLevel string `yaml:"min_level" json:"min_level"` + JSON *JSONConfig `yaml:"json" json:"json"` + LevelDetection *LevelDetectionConfig `yaml:"level_detection" json:"level_detection"` + Multiline *MultilineConfig `yaml:"multiline" json:"multiline"` + Filters *FilterConfig `yaml:"filters" json:"filters"` +} + +// RotateConfig configures size-based log file rotation +type RotateConfig struct { + MaxSize string `yaml:"max_size" json:"max_size"` // Human-readable size: "50MB", "100KB", "1GB" + MaxFiles int `yaml:"max_files" json:"max_files"` // Number of rotated files to keep +} + +// ParseSize parses a human-readable size string (e.g., "50MB", "100KB", "1GB") into bytes. +func ParseSize(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty size string") + } + + s = strings.ToUpper(s) + + multipliers := []struct { + suffix string + mult int64 + }{ + {"GB", 1024 * 1024 * 1024}, + {"MB", 1024 * 1024}, + {"KB", 1024}, + {"B", 1}, + } + + for _, m := range multipliers { + if strings.HasSuffix(s, m.suffix) { + numStr := strings.TrimSuffix(s, m.suffix) + numStr = strings.TrimSpace(numStr) + val, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, fmt.Errorf("invalid size number %q: %w", numStr, err) + } + if val <= 0 { + return 0, fmt.Errorf("size must be positive, got %s", s) + } + return int64(val * float64(m.mult)), nil + } + } + + return 0, fmt.Errorf("invalid size format %q (use KB, MB, or GB suffix)", s) +} + // TLSConfig configures TLS/HTTPS for API and metrics endpoints type TLSConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` // Enable TLS @@ -513,6 +573,24 @@ func (c *Config) setProcessLoggingAdvancedDefaults(proc *Process) { if proc.Logging.MinLevel == "" { proc.Logging.MinLevel = "info" } + + // Set defaults for log file tailing configs + for _, fileCfg := range proc.Logging.Files { + if fileCfg.Multiline != nil { + if fileCfg.Multiline.MaxLines == 0 { + fileCfg.Multiline.MaxLines = 100 + } + if fileCfg.Multiline.Timeout == 0 { + fileCfg.Multiline.Timeout = 1 + } + } + if fileCfg.LevelDetection != nil && fileCfg.LevelDetection.DefaultLevel == "" { + fileCfg.LevelDetection.DefaultLevel = "info" + } + if fileCfg.Rotate != nil && fileCfg.Rotate.MaxFiles == 0 { + fileCfg.Rotate.MaxFiles = 5 + } + } } // SetDefaults sets sensible default values for the configuration From 359c49e4f44efcf2df3301dbf4c5bb3d91cc1123 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 15:34:34 +0200 Subject: [PATCH 12/18] feat: add validation and defaults for log file tailing config Add validation for log file path (required), rotate.max_size (ParseSize), and rotate.max_files (non-negative). Add defaults for file-level multiline (max_lines=100, timeout=1s), level detection (default_level=info), and rotation (max_files=5). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/config/validation.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/config/validation.go b/internal/config/validation.go index 2a1fad0..bab40c2 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -445,6 +445,25 @@ func (c *Config) validateProcessLoggingConfig(name string, proc *Process, result if !proc.Logging.Stdout && !proc.Logging.Stderr { result.AddProcessWarning(name, "logging", "Both stdout and stderr disabled", "Enable at least one stream for process output visibility") } + + // Validate log file tailing configuration + if proc.Logging.Files != nil { + for fileName, fileCfg := range proc.Logging.Files { + if fileCfg.Path == "" { + result.AddProcessError(name, fmt.Sprintf("logging.files.%s.path", fileName), "Path is required", "Specify the file path to tail") + } + if fileCfg.Rotate != nil { + if fileCfg.Rotate.MaxSize != "" { + if _, err := ParseSize(fileCfg.Rotate.MaxSize); err != nil { + result.AddProcessError(name, fmt.Sprintf("logging.files.%s.rotate.max_size", fileName), fmt.Sprintf("Invalid size: %v", err), "Use format like '50MB', '1GB'") + } + } + if fileCfg.Rotate.MaxFiles < 0 { + result.AddProcessError(name, fmt.Sprintf("logging.files.%s.rotate.max_files", fileName), "max_files must be non-negative", "Set max_files >= 0") + } + } + } + } } // validateHealthCheck validates health check configuration From d5ddefd252b766c9fd4967d578c60d3778a55da7 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 15:39:31 +0200 Subject: [PATCH 13/18] feat: add FileRotator for size-based log file rotation Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/logtail/rotator.go | 68 ++++++++++++++++++++ internal/logtail/rotator_test.go | 104 +++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 internal/logtail/rotator.go create mode 100644 internal/logtail/rotator_test.go diff --git a/internal/logtail/rotator.go b/internal/logtail/rotator.go new file mode 100644 index 0000000..4858c5b --- /dev/null +++ b/internal/logtail/rotator.go @@ -0,0 +1,68 @@ +package logtail + +import ( + "fmt" + "os" +) + +// 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. +type FileRotator struct { + MaxSize int64 + MaxFiles int +} + +// NewFileRotator creates a new FileRotator. +func NewFileRotator(maxSize int64, maxFiles int) *FileRotator { + return &FileRotator{MaxSize: maxSize, MaxFiles: maxFiles} +} + +// CheckAndRotate checks if the file exceeds MaxSize and rotates if needed. +// Returns nil if file doesn't exist (file may not have been created yet). +func (r *FileRotator) CheckAndRotate(path string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat %s: %w", path, err) + } + + if info.Size() < r.MaxSize { + return nil + } + + // Shift existing rotated files: .N -> .N+1, starting from highest + for i := r.MaxFiles; i >= 1; i-- { + src := fmt.Sprintf("%s.%d", path, i) + dst := fmt.Sprintf("%s.%d", path, i+1) + + if i == r.MaxFiles { + os.Remove(dst) + } + + if _, err := os.Stat(src); err == nil { + if i >= r.MaxFiles { + os.Remove(src) + } else { + os.Rename(src, dst) + } + } + } + + // 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) + } + + // Create new empty file + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + f.Close() + + return nil +} diff --git a/internal/logtail/rotator_test.go b/internal/logtail/rotator_test.go new file mode 100644 index 0000000..a825802 --- /dev/null +++ b/internal/logtail/rotator_test.go @@ -0,0 +1,104 @@ +package logtail + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFileRotator_NoRotationNeeded(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + os.WriteFile(path, []byte("small"), 0644) + + r := NewFileRotator(1024, 3) + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, _ := os.ReadFile(path) + if string(data) != "small" { + t.Errorf("file was modified: %q", data) + } +} + +func TestFileRotator_RotatesWhenOverSize(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + os.WriteFile(path, make([]byte, 2048), 0644) + + r := NewFileRotator(1024, 3) + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("rotation failed: %v", err) + } + + info, _ := os.Stat(path) + if info.Size() != 0 { + t.Errorf("expected truncated file, got size %d", info.Size()) + } + + rData, err := os.ReadFile(path + ".1") + if err != nil { + t.Fatalf("rotated file missing: %v", err) + } + if len(rData) != 2048 { + t.Errorf("rotated file size %d, expected 2048", len(rData)) + } +} + +func TestFileRotator_ShiftsExistingFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + + os.WriteFile(path+".1", []byte("old-1"), 0644) + os.WriteFile(path+".2", []byte("old-2"), 0644) + os.WriteFile(path, make([]byte, 2048), 0644) + + r := NewFileRotator(1024, 3) + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("rotation failed: %v", err) + } + + info, _ := os.Stat(path + ".1") + if info.Size() != 2048 { + t.Errorf(".1 size %d, expected 2048", info.Size()) + } + data, _ := os.ReadFile(path + ".2") + if string(data) != "old-1" { + t.Errorf(".2 content %q, expected 'old-1'", data) + } + data, _ = os.ReadFile(path + ".3") + if string(data) != "old-2" { + t.Errorf(".3 content %q, expected 'old-2'", data) + } +} + +func TestFileRotator_DeletesBeyondMaxFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + + os.WriteFile(path+".1", []byte("one"), 0644) + os.WriteFile(path+".2", []byte("two"), 0644) + os.WriteFile(path, make([]byte, 2048), 0644) + + r := NewFileRotator(1024, 2) + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("rotation failed: %v", err) + } + + if _, err := os.Stat(path + ".1"); err != nil { + t.Error(".1 should exist") + } + if _, err := os.Stat(path + ".2"); err != nil { + t.Error(".2 should exist") + } + if _, err := os.Stat(path + ".3"); !os.IsNotExist(err) { + t.Error(".3 should have been deleted") + } +} + +func TestFileRotator_MissingFile(t *testing.T) { + r := NewFileRotator(1024, 3) + if err := r.CheckAndRotate("/tmp/nonexistent-logtail-test.log"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} From 14b3e62f37d228e6d35f1c7e768829616f404110 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 15:39:37 +0200 Subject: [PATCH 14/18] feat: add FileTailer with pure Go tail -F semantics Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/logtail/tailer.go | 204 +++++++++++++++++++++++++++++ internal/logtail/tailer_test.go | 219 ++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 internal/logtail/tailer.go create mode 100644 internal/logtail/tailer_test.go diff --git a/internal/logtail/tailer.go b/internal/logtail/tailer.go new file mode 100644 index 0000000..fa17b6e --- /dev/null +++ b/internal/logtail/tailer.go @@ -0,0 +1,204 @@ +package logtail + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/fsnotify/fsnotify" +) + +// FileTailer implements tail -F semantics in pure Go. +type FileTailer struct { + path string + writer io.Writer + rotator *FileRotator +} + +// New creates a new FileTailer. writer receives complete lines. +// rotator is optional (nil to disable rotation). +func New(path string, writer io.Writer, rotator *FileRotator) *FileTailer { + return &FileTailer{path: path, writer: writer, rotator: rotator} +} + +// Start begins tailing the file. Blocks until ctx is cancelled. +func (t *FileTailer) Start(ctx context.Context) error { + existed := true + if _, err := os.Stat(t.path); err != nil { + existed = false + } + if err := t.waitForFile(ctx); err != nil { + return err + } + return t.tailLoop(ctx, existed) +} + +// Stop is a no-op — use context cancellation. +func (t *FileTailer) Stop() error { + return nil +} + +func (t *FileTailer) waitForFile(ctx context.Context) error { + if _, err := os.Stat(t.path); err == nil { + return nil + } + + dir := filepath.Dir(t.path) + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("create watcher: %w", err) + } + defer watcher.Close() + + if err := watcher.Add(dir); err != nil { + return t.pollForFile(ctx) + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event, ok := <-watcher.Events: + if !ok { + return fmt.Errorf("watcher closed") + } + if event.Name == t.path && (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) { + return nil + } + case err, ok := <-watcher.Errors: + if !ok { + return fmt.Errorf("watcher error channel closed") + } + return fmt.Errorf("watcher error: %w", err) + } + } +} + +func (t *FileTailer) pollForFile(ctx context.Context) error { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if _, err := os.Stat(t.path); err == nil { + return nil + } + } + } +} + +func (t *FileTailer) tailLoop(ctx context.Context, seekToEnd bool) error { + file, err := os.Open(t.path) + if err != nil { + return fmt.Errorf("open %s: %w", t.path, err) + } + defer file.Close() + + var offset int64 + if seekToEnd { + offset, err = file.Seek(0, io.SeekEnd) + if err != nil { + return fmt.Errorf("seek to end: %w", err) + } + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("create watcher: %w", err) + } + defer watcher.Close() + + if err := watcher.Add(t.path); err != nil { + return fmt.Errorf("watch file: %w", err) + } + dir := filepath.Dir(t.path) + _ = watcher.Add(dir) + + reader := bufio.NewReader(file) + + // readLines reads all available complete lines from the current position. + readLines := func() { + for { + line, err := reader.ReadString('\n') + if err != nil { + if len(line) > 0 { + // Incomplete line — seek back so we re-read it next time + file.Seek(offset, io.SeekStart) + reader.Reset(file) + } + break + } + offset += int64(len(line)) + t.writer.Write([]byte(line)) + } + + newOffset, _ := file.Seek(0, io.SeekCurrent) + if newOffset > offset { + offset = newOffset + } + } + + // If we didn't seek to end, read any existing content now + if !seekToEnd { + readLines() + } + + for { + select { + case <-ctx.Done(): + return nil + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + info, err := os.Stat(t.path) + if err != nil { + continue + } + if info.Size() < offset { + file.Seek(0, io.SeekStart) + offset = 0 + reader.Reset(file) + } + + readLines() + + if t.rotator != nil { + t.rotator.CheckAndRotate(t.path) + } + } + + if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + file.Close() + watcher.Remove(t.path) + + if err := t.waitForFile(ctx); err != nil { + return err + } + + file, err = os.Open(t.path) + if err != nil { + return fmt.Errorf("reopen %s: %w", t.path, err) + } + defer file.Close() + + offset = 0 + reader = bufio.NewReader(file) + _ = watcher.Add(t.path) + } + + case _, ok := <-watcher.Errors: + if !ok { + return nil + } + } + } +} diff --git a/internal/logtail/tailer_test.go b/internal/logtail/tailer_test.go new file mode 100644 index 0000000..2fa4903 --- /dev/null +++ b/internal/logtail/tailer_test.go @@ -0,0 +1,219 @@ +package logtail + +import ( + "bytes" + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +type lineCollector struct { + mu sync.Mutex + lines []string + buf bytes.Buffer +} + +func (lc *lineCollector) Write(p []byte) (int, error) { + lc.mu.Lock() + defer lc.mu.Unlock() + lc.buf.Write(p) + for { + line, err := lc.buf.ReadString('\n') + if err != nil { + lc.buf.WriteString(line) + break + } + lc.lines = append(lc.lines, line[:len(line)-1]) + } + return len(p), nil +} + +func (lc *lineCollector) Lines() []string { + lc.mu.Lock() + defer lc.mu.Unlock() + result := make([]string, len(lc.lines)) + copy(result, lc.lines) + return result +} + +func TestFileTailer_FollowNewLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("line one\n") + f.WriteString("line two\n") + f.Close() + + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + if len(lines) < 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "line one" { + t.Errorf("line 0: %q", lines[0]) + } + if lines[1] != "line two" { + t.Errorf("line 1: %q", lines[1]) + } +} + +func TestFileTailer_SeeksToEnd(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + os.WriteFile(path, []byte("old line\n"), 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("new line\n") + f.Close() + + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + for _, l := range lines { + if l == "old line" { + t.Error("should not see pre-existing content") + } + } + if len(lines) == 0 || lines[len(lines)-1] != "new line" { + t.Errorf("expected 'new line', got %v", lines) + } +} + +func TestFileTailer_DetectsTruncation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("before truncate\n") + f.Close() + time.Sleep(300 * time.Millisecond) + + os.Truncate(path, 0) + time.Sleep(200 * time.Millisecond) + + f, _ = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("after truncate\n") + f.Close() + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + found := false + for _, l := range lines { + if l == "after truncate" { + found = true + } + } + if !found { + t.Errorf("expected 'after truncate' in lines: %v", lines) + } +} + +func TestFileTailer_WaitsForMissingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "not-yet.log") + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + f, _ := os.Create(path) + f.WriteString("appeared\n") + f.Close() + + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + if len(lines) == 0 || lines[0] != "appeared" { + t.Errorf("expected 'appeared', got %v", lines) + } +} + +func TestFileTailer_StopsOnContextCancel(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { done <- tailer.Start(ctx) }() + + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("tailer did not stop") + } +} + +func TestFileTailer_WithRotator(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + rotator := NewFileRotator(100, 2) + tailer := New(path, collector, rotator) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + for i := 0; i < 20; i++ { + f.WriteString("this is a log line that is fairly long to fill up space\n") + } + f.Close() + + time.Sleep(500 * time.Millisecond) + + if _, err := os.Stat(path + ".1"); os.IsNotExist(err) { + t.Error("expected rotated file .1 to exist") + } +} From 04c843dad083cb3aa08bd790523eb125e8679d37 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 15:44:57 +0200 Subject: [PATCH 15/18] feat: integrate FileTailers into Supervisor lifecycle Wire file tailers into the Supervisor so that configured log files are tailed when the supervisor starts and stopped on shutdown. Tailers are config-bound (survive process restarts) and each gets a ProcessWriter with inherited redaction config plus per-file logging settings. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/process/supervisor.go | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/internal/process/supervisor.go b/internal/process/supervisor.go index 5d28762..7892bf0 100644 --- a/internal/process/supervisor.go +++ b/internal/process/supervisor.go @@ -16,6 +16,7 @@ import ( "github.com/cboxdk/init/internal/config" "github.com/cboxdk/init/internal/hooks" "github.com/cboxdk/init/internal/logger" + "github.com/cboxdk/init/internal/logtail" "github.com/cboxdk/init/internal/metrics" ) @@ -106,6 +107,7 @@ type Supervisor struct { deathNotifier func(string) // Callback when all instances are dead credentials *Credentials // Resolved user/group credentials (nil = inherit) logBroadcaster *logger.LogBroadcaster // Shared broadcaster for real-time log subscriptions + fileTailers map[string]context.CancelFunc // active file tailers, keyed by config name healthCheckStrict bool // Fail startup if health monitor creation fails ctx context.Context cancel context.CancelFunc @@ -345,6 +347,9 @@ func (s *Supervisor) Start(ctx context.Context) error { s.state = StateRunning + // Start file tailers (config-bound, independent of process instances) + s.startFileTailers(s.ctx) + // Start health monitoring if configured if s.config.HealthCheck != nil { monitor, err := NewHealthMonitor(s.name, s.config.HealthCheck, s.logger) @@ -516,6 +521,82 @@ func (s *Supervisor) startInstance(ctx context.Context, instanceID string, insta return instance, nil } +// startFileTailers starts file tailers for all configured log files. +// Each tailer gets its own ProcessWriter with the file's logging config +// plus the process-level redaction config. +// Tailers are bound to configuration, not process state — they survive restarts. +func (s *Supervisor) startFileTailers(ctx context.Context) { + if s.config.Logging == nil || len(s.config.Logging.Files) == 0 { + return + } + + if s.fileTailers == nil { + s.fileTailers = make(map[string]context.CancelFunc) + } + + for name, fileCfg := range s.config.Logging.Files { + // Build a LoggingConfig for this file, inheriting process-level redaction + fileLogging := &config.LoggingConfig{ + Redaction: s.config.Logging.Redaction, // Inherited from process + MinLevel: fileCfg.MinLevel, + JSON: fileCfg.JSON, + LevelDetection: fileCfg.LevelDetection, + Multiline: fileCfg.Multiline, + Filters: fileCfg.Filters, + } + + // Create a ProcessWriter for this file + pw, err := logger.NewProcessWriter(s.logger, s.name, name, "file", fileLogging) + if err != nil { + s.logger.Error("Failed to create process writer for log file", + "file", name, "path", fileCfg.Path, "error", err) + continue + } + + // Wire broadcaster for real-time subscriptions + if s.logBroadcaster != nil { + pw.SetBroadcaster(s.logBroadcaster) + } + + // Create optional rotator + var rotator *logtail.FileRotator + if fileCfg.Rotate != nil && fileCfg.Rotate.MaxSize != "" { + maxBytes, err := config.ParseSize(fileCfg.Rotate.MaxSize) + if err != nil { + s.logger.Error("Invalid rotate max_size for log file", + "file", name, "error", err) + continue + } + rotator = logtail.NewFileRotator(maxBytes, fileCfg.Rotate.MaxFiles) + } + + // Create and start tailer + tailer := logtail.New(fileCfg.Path, pw, rotator) + + tailerCtx, tailerCancel := context.WithCancel(ctx) + s.fileTailers[name] = tailerCancel + + s.goroutines.Add(1) + go func(n, p string) { + defer s.goroutines.Done() + if err := tailer.Start(tailerCtx); err != nil && tailerCtx.Err() == nil { + s.logger.Error("File tailer error", "file", n, "path", p, "error", err) + } + }(name, fileCfg.Path) + + s.logger.Info("Started file tailer", "file", name, "path", fileCfg.Path) + } +} + +// stopFileTailers stops all active file tailers. +func (s *Supervisor) stopFileTailers() { + for name, cancel := range s.fileTailers { + cancel() + s.logger.Debug("Stopped file tailer", "file", name) + } + s.fileTailers = nil +} + // monitorInstance monitors a process instance and handles restarts func (s *Supervisor) monitorInstance(instance *Instance) { // CRITICAL: Panic recovery to prevent goroutine crashes from killing daemon @@ -723,6 +804,9 @@ func (s *Supervisor) Stop(ctx context.Context) error { s.cancel() } + // Stop file tailers + s.stopFileTailers() + var wg sync.WaitGroup errChan := make(chan error, len(s.instances)) From 2a0671ddf3a90878824076e6bd588811a2d542e6 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 16:06:30 +0200 Subject: [PATCH 16/18] docs: add design specs and implementation plans Add specs and plans for: - CLI commands & always-on Unix socket - Log file tailing with rotation Co-Authored-By: Claude Opus 4.6 (1M context) --- ...04-17-cli-commands-and-always-on-socket.md | 1912 +++++++++++++++++ .../plans/2026-04-17-log-file-tailing.md | 1162 ++++++++++ ...li-commands-and-always-on-socket-design.md | 318 +++ .../2026-04-17-log-file-tailing-design.md | 180 ++ 4 files changed, 3572 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-cli-commands-and-always-on-socket.md create mode 100644 docs/superpowers/plans/2026-04-17-log-file-tailing.md create mode 100644 docs/superpowers/specs/2026-04-17-cli-commands-and-always-on-socket-design.md create mode 100644 docs/superpowers/specs/2026-04-17-log-file-tailing-design.md diff --git a/docs/superpowers/plans/2026-04-17-cli-commands-and-always-on-socket.md b/docs/superpowers/plans/2026-04-17-cli-commands-and-always-on-socket.md new file mode 100644 index 0000000..b0ef730 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-cli-commands-and-always-on-socket.md @@ -0,0 +1,1912 @@ +# CLI Commands & Always-On Unix Socket — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable local process management via always-on Unix socket and full CLI command suite (list, status, start, stop, restart, scale, reload-config, logs with SSE streaming). + +**Architecture:** Extract the existing TUI API client to `internal/apiclient/` for shared use. Make the daemon always start a Unix socket listener regardless of `api_enabled`. Add CLI commands as thin Cobra wrappers that auto-discover the socket. Add SSE log streaming via a pub/sub mechanism on the process manager's log buffers. + +**Tech Stack:** Go 1.24, Cobra CLI, net/http SSE, Unix sockets + +**Spec:** `docs/superpowers/specs/2026-04-17-cli-commands-and-always-on-socket-design.md` + +--- + +## File Structure + +### New files +| File | Responsibility | +|------|---------------| +| `internal/apiclient/client.go` | API client (moved from tui/client.go), auto-discovery, all HTTP methods | +| `internal/apiclient/client_test.go` | Client tests (moved from tui/client_test.go) | +| `internal/apiclient/stream.go` | SSE log streaming client (`StreamLogs`) | +| `internal/apiclient/stream_test.go` | SSE stream tests | +| `internal/logger/subscriber.go` | Log pub/sub: `LogBroadcaster` with Subscribe/Unsubscribe | +| `internal/logger/subscriber_test.go` | Broadcaster tests | +| `cmd/cbox-init/list.go` | `cbox-init list` command | +| `cmd/cbox-init/status.go` | `cbox-init status ` command | +| `cmd/cbox-init/start_cmd.go` | `cbox-init start ` command | +| `cmd/cbox-init/stop_cmd.go` | `cbox-init stop ` command | +| `cmd/cbox-init/restart.go` | `cbox-init restart ` command | +| `cmd/cbox-init/scale.go` | `cbox-init scale ` command | +| `cmd/cbox-init/reload_config.go` | `cbox-init reload-config` command | + +### Modified files +| File | Change | +|------|--------| +| `internal/tui/model.go` | Change `client *APIClient` to `client *apiclient.Client`, update constructors | +| `internal/tui/tui.go` | Update imports to use `apiclient.New` | +| `internal/tui/update.go` | Update client method calls (if signatures change) | +| `internal/tui/client_test.go` | Remove (moved to apiclient) | +| `internal/tui/client.go` | Remove (moved to apiclient) | +| `internal/logger/log_buffer.go` | Add `AddWithBroadcast` or hook broadcaster into `Add` | +| `internal/logger/process_writer.go` | Wire broadcaster into log entry pipeline | +| `internal/process/manager.go` | Expose `LogBroadcaster` via getter, pass to supervisors | +| `internal/process/manager_logs.go` | Add `SubscribeLogs` method | +| `internal/api/server.go` | Add `StartSocketOnly` method, add SSE `/api/v1/logs/stream` endpoint | +| `cmd/cbox-init/serve.go` | Always start socket, TCP gated behind `api_enabled` | +| `cmd/cbox-init/root.go` | Register new commands, update help examples | +| `cmd/cbox-init/logs.go` | Rewrite to use apiclient + SSE streaming | +| `cmd/cbox-init/tui.go` | Update error message (remove "Ensure API is enabled" hint) | + +--- + +## Task 1: Extract API Client to `internal/apiclient/` + +Move the existing TUI client to a shared package. No behavior changes — pure refactoring. + +**Files:** +- Create: `internal/apiclient/client.go` +- Create: `internal/apiclient/client_test.go` +- Delete: `internal/tui/client.go` +- Delete: `internal/tui/client_test.go` +- Modify: `internal/tui/model.go` +- Modify: `internal/tui/tui.go` +- Modify: `internal/tui/update.go` + +- [ ] **Step 1: Create `internal/apiclient/client.go`** + +Copy `internal/tui/client.go` to `internal/apiclient/client.go`. Change package declaration to `package apiclient`. Rename the type from `APIClient` to `Client`. Update the constructor from `NewAPIClient` to `New`. All methods stay the same. Update imports: + +```go +package apiclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/cboxdk/init/internal/config" + "github.com/cboxdk/init/internal/logger" + "github.com/cboxdk/init/internal/process" +) + +// Client connects to a running Cbox Init daemon via API +type Client struct { + baseURL string + socketPath string + auth string + client *http.Client +} + +// New creates a new API client with auto-detection +// Tries Unix socket first, falls back to TCP +func New(baseURL, auth string) *Client { + client := &Client{ + baseURL: baseURL, + auth: auth, + } + + // Auto-detect socket paths (priority order) + socketPaths := []string{ + "/var/run/cbox-init.sock", + "/tmp/cbox-init.sock", + "/run/cbox-init.sock", + } + + // Try each socket path + for _, socketPath := range socketPaths { + if client.trySocket(socketPath) { + client.socketPath = socketPath + client.client = client.createSocketClient(socketPath) + return client + } + } + + // Fall back to TCP + client.client = &http.Client{ + Timeout: 10 * time.Second, + } + + return client +} +``` + +All remaining methods are copied verbatim but with receiver type `*Client` instead of `*APIClient`. No logic changes. + +- [ ] **Step 2: Create `internal/apiclient/client_test.go`** + +Copy `internal/tui/client_test.go` to `internal/apiclient/client_test.go`. Change package to `package apiclient`. Replace all `NewAPIClient` calls with `New`. Replace all `APIClient` type references with `Client`. All test logic stays the same. + +- [ ] **Step 3: Run apiclient tests to verify they pass** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/apiclient/ -v -count=1` +Expected: All tests PASS + +- [ ] **Step 4: Update `internal/tui/model.go`** + +Change the `client` field type and update constructors: + +In the import block, add: +```go +"github.com/cboxdk/init/internal/apiclient" +``` + +Change the Model struct field (line 74): +```go +// Old: +client *APIClient // For remote mode +// New: +client *apiclient.Client // For remote mode +``` + +Change `NewRemoteModel` (line 163-176): +```go +// Old: +client: NewAPIClient(apiURL, auth), +// New: +client: apiclient.New(apiURL, auth), +``` + +- [ ] **Step 5: Update `internal/tui/tui.go`** + +No import changes needed — `tui.go` only calls `model.client.HealthCheck()` which already goes through the model. The model owns the client reference. No changes required to this file. + +- [ ] **Step 6: Update `internal/tui/update.go`** + +Check if `update.go` references `APIClient` type directly. It should only reference `m.client` which is already typed via the model. If there are any direct type references (e.g. in type assertions), update them to `*apiclient.Client`. Based on code review, `update.go` only uses `m.client.MethodName()` calls — no type references to update. + +- [ ] **Step 7: Delete old files** + +Remove `internal/tui/client.go` and `internal/tui/client_test.go`. + +- [ ] **Step 8: Run all TUI tests to verify refactoring** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/tui/ -v -count=1` +Expected: All tests PASS + +- [ ] **Step 9: Run full test suite** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./... -count=1` +Expected: All tests PASS + +- [ ] **Step 10: Commit** + +```bash +git add internal/apiclient/ internal/tui/ +git commit -m "refactor: extract API client to internal/apiclient for shared CLI/TUI use" +``` + +--- + +## Task 2: Always-On Unix Socket + +Make the daemon always start a Unix socket listener, regardless of `api_enabled`. + +**Files:** +- Modify: `internal/api/server.go` +- Modify: `cmd/cbox-init/serve.go` +- Modify: `cmd/cbox-init/tui.go` + +- [ ] **Step 1: Add `StartSocketOnly` method to `internal/api/server.go`** + +Add a new method after `Start()` (after line 341). This method creates the mux with all routes (same as `Start`) but only starts the socket listener — no TCP, no ACL, no rate limiter on socket: + +```go +// StartSocketOnly starts only the Unix socket listener (no TCP). +// Used when api_enabled is false but local management is still needed. +// The socket handler has no ACL or rate limiting — file permissions provide security. +func (s *Server) StartSocketOnly(ctx context.Context) error { + if s.socketPath == "" { + return fmt.Errorf("no socket path configured") + } + + mux := http.NewServeMux() + + // Same routes as Start() but without rate limiting or ACL + mux.HandleFunc("/api/v1/health", s.wrapHandler(s.handleHealth, false)) + mux.HandleFunc("/api/v1/processes", s.wrapHandler(s.handleProcesses, false)) + mux.HandleFunc("/api/v1/processes/", s.wrapHandler(s.handleProcessAction, false)) + mux.HandleFunc("/api/v1/logs", s.wrapHandler(s.handleStackLogs, false)) + mux.HandleFunc("/api/v1/config/save", s.wrapHandler(s.handleConfigSave, false)) + mux.HandleFunc("/api/v1/config/reload", s.wrapHandler(s.handleConfigReload, false)) + mux.HandleFunc("/api/v1/metrics/history", s.wrapHandler(s.handleMetricsHistory, false)) + mux.HandleFunc("/api/v1/oneshot/history", s.wrapHandler(s.handleOneshotHistory, false)) + + return s.startSocketListener(mux) +} +``` + +Note: `requireAuth` is set to `false` for all routes when socket-only — socket relies on file permissions for security. + +Also update socket permissions from `0600` to `0660` in `startSocketListener` (line 357 of server.go) to allow group access as specified in the design: + +```go +// Old: +if err := os.Chmod(s.socketPath, 0600); err != nil { +// New: +if err := os.Chmod(s.socketPath, 0660); err != nil { +``` + +- [ ] **Step 2: Add `resolveSocketPath` helper to `cmd/cbox-init/serve.go`** + +Add before `startAPIServer`: + +```go +// resolveSocketPath determines the Unix socket path. +// Priority: config value > /var/run/cbox-init.sock > /tmp/cbox-init.sock +func resolveSocketPath(configured string) string { + if configured != "" { + return configured + } + + // Try /var/run first (preferred, persistent) + testPath := "/var/run/cbox-init.sock" + if dir := filepath.Dir(testPath); dirWritable(dir) { + return testPath + } + + // Fall back to /tmp + return "/tmp/cbox-init.sock" +} + +// dirWritable checks if a directory is writable +func dirWritable(dir string) bool { + testFile := filepath.Join(dir, ".cbox-init-write-test") + f, err := os.Create(testFile) + if err != nil { + return false + } + f.Close() + os.Remove(testFile) + return true +} +``` + +Add `"path/filepath"` to the imports in serve.go. + +- [ ] **Step 3: Modify `serve.go` to always start socket** + +Change the API server startup section (around lines 176-180): + +```go +// Always start Unix socket for local management (TUI, CLI commands) +socketPath := resolveSocketPath(cfg.Global.APISocket) + +// Start API server +var apiServer *api.Server +if cfg.Global.APIEnabledValue() { + // Full API: TCP + socket + if cfg.Global.APISocket == "" { + cfg.Global.APISocket = socketPath + } + apiServer = startAPIServer(ctx, cfg, pm, log) +} else { + // Socket-only mode: local management without TCP + apiServer = api.NewServer(0, socketPath, "", nil, nil, cfg.Global.AuditEnabled, cfg.Global.APIMaxRequestBody, pm, log) + if err := apiServer.StartSocketOnly(ctx); err != nil { + slog.Warn("Failed to start Unix socket (local CLI/TUI disabled)", "error", err) + apiServer = nil + } else { + slog.Info("Unix socket started (local management only)", "path", socketPath) + } +} +``` + +- [ ] **Step 4: Update TUI error message in `cmd/cbox-init/tui.go`** + +The old error message tells users to enable `api_enabled`. Since the socket is now always active, update the hint (lines 56-63): + +```go + if err := tui.RunRemote(apiURL, auth); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Remote TUI error: %v\n", err) + fmt.Fprintf(os.Stderr, "\nšŸ’” Make sure daemon is running:\n") + fmt.Fprintf(os.Stderr, " cbox-init serve\n\n") + fmt.Fprintf(os.Stderr, "šŸ’” For remote access, ensure API is enabled:\n") + fmt.Fprintf(os.Stderr, " global:\n") + fmt.Fprintf(os.Stderr, " api_enabled: true\n") + fmt.Fprintf(os.Stderr, " api_port: 9180\n") + os.Exit(1) + } +``` + +- [ ] **Step 5: Build and verify** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && make build` +Expected: Build succeeds + +- [ ] **Step 6: Run tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/api/ ./cmd/cbox-init/ -v -count=1` +Expected: All tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/server.go cmd/cbox-init/serve.go cmd/cbox-init/tui.go +git commit -m "feat: always-on Unix socket for local management without TCP" +``` + +--- + +## Task 3: CLI Helper + `list` and `status` Commands + +Create shared CLI helper and the read-only commands. + +**Files:** +- Create: `cmd/cbox-init/list.go` +- Create: `cmd/cbox-init/status.go` +- Modify: `cmd/cbox-init/root.go` + +- [ ] **Step 1: Create `cmd/cbox-init/list.go`** + +```go +package main + +import ( + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/cboxdk/init/internal/apiclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all processes and their status", + Long: `Display a table of all managed processes with their current state, scale, restart count, and uptime.`, + Args: cobra.NoArgs, + Run: runList, +} + +var listURL string + +func init() { + listCmd.Flags().StringVar(&listURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runList(cmd *cobra.Command, args []string) { + client := newClient(listURL) + + processes, err := client.ListProcesses() + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to list processes: %v\n", err) + os.Exit(1) + } + + if len(processes) == 0 { + fmt.Println("No processes configured") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATUS\tSCALE\tRESTARTS\tUPTIME") + + hasUnhealthy := false + for _, p := range processes { + status := p.State + if status != "running" { + hasUnhealthy = true + } + + scale := fmt.Sprintf("%d/%d", p.Scale, p.DesiredScale) + + // Calculate restarts from instances + restarts := 0 + var uptime string + for _, inst := range p.Instances { + restarts += inst.RestartCount + if inst.StartedAt > 0 && uptime == "" { + d := time.Since(time.Unix(inst.StartedAt, 0)) + uptime = formatDuration(d) + } + } + if uptime == "" { + uptime = "-" + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", p.Name, status, scale, restarts, uptime) + } + w.Flush() + + if hasUnhealthy { + os.Exit(1) + } +} + +// newClient creates an API client, using --url flag or auto-discovery +func newClient(urlFlag string) *apiclient.Client { + auth := os.Getenv("CBOX_INIT_API_AUTH") + if urlFlag != "" { + return apiclient.New(urlFlag, auth) + } + return apiclient.New("http://localhost:9180", auth) +} + +// formatDuration formats a duration as human-readable (e.g., "2h34m", "5m12s") +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) + } + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%dd%dh", days, hours) +} +``` + +- [ ] **Step 2: Create `cmd/cbox-init/status.go`** + +```go +package main + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status ", + Short: "Show detailed status of a process", + Long: `Display detailed information about a specific process including PID, scale, restarts, uptime, command, and health status.`, + Args: cobra.ExactArgs(1), + Run: runStatus, +} + +var statusURL string + +func init() { + statusCmd.Flags().StringVar(&statusURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runStatus(cmd *cobra.Command, args []string) { + processName := args[0] + client := newClient(statusURL) + + processes, err := client.ListProcesses() + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to get process status: %v\n", err) + os.Exit(1) + } + + // Find the process + var found bool + for _, p := range processes { + if p.Name != processName { + continue + } + found = true + + fmt.Printf("Name: %s\n", p.Name) + fmt.Printf("Type: %s\n", p.Type) + fmt.Printf("Status: %s\n", p.State) + fmt.Printf("Scale: %d/%d\n", p.Scale, p.DesiredScale) + + if p.MaxScale > 0 { + fmt.Printf("Max Scale: %d\n", p.MaxScale) + } + + // Aggregate instance info + restarts := 0 + for _, inst := range p.Instances { + restarts += inst.RestartCount + } + fmt.Printf("Restarts: %d\n", restarts) + + // Show instance details + if len(p.Instances) > 0 { + for _, inst := range p.Instances { + var uptime string + if inst.StartedAt > 0 { + d := time.Since(time.Unix(inst.StartedAt, 0)) + uptime = formatDuration(d) + } else { + uptime = "-" + } + fmt.Printf("Instance: %s (pid=%d, state=%s, uptime=%s, restarts=%d)\n", + inst.ID, inst.PID, inst.State, uptime, inst.RestartCount) + } + } + + // Show CPU/Memory if available + if p.CPUPercent > 0 || p.MemoryRSSBytes > 0 { + fmt.Printf("CPU: %.1f%%\n", p.CPUPercent) + fmt.Printf("Memory: %s\n", formatBytes(p.MemoryRSSBytes)) + } + + // Schedule info + if p.Schedule != "" { + fmt.Printf("Schedule: %s\n", p.Schedule) + fmt.Printf("Sched State:%s\n", p.ScheduleState) + if p.NextRun > 0 { + fmt.Printf("Next Run: %s\n", time.Unix(p.NextRun, 0).Format(time.RFC3339)) + } + } + + break + } + + if !found { + fmt.Fprintf(os.Stderr, "āŒ Process not found: %s\n", processName) + os.Exit(1) + } +} + +// formatBytes formats bytes as human-readable +func formatBytes(b uint64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case b >= GB: + return fmt.Sprintf("%.1fG", float64(b)/float64(GB)) + case b >= MB: + return fmt.Sprintf("%.1fM", float64(b)/float64(MB)) + case b >= KB: + return fmt.Sprintf("%.1fK", float64(b)/float64(KB)) + default: + return fmt.Sprintf("%dB", b) + } +} +``` + +- [ ] **Step 3: Register commands in `cmd/cbox-init/root.go`** + +Add to the `init()` function, replacing the commented-out lines (lines 64-69): + +```go + // Process control commands + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(statusCmd) +``` + +Keep the other commented-out lines for now — we'll replace them in later tasks. + +- [ ] **Step 4: Build and verify** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && make build` +Expected: Build succeeds + +- [ ] **Step 5: Commit** + +```bash +git add cmd/cbox-init/list.go cmd/cbox-init/status.go cmd/cbox-init/root.go +git commit -m "feat: add list and status CLI commands" +``` + +--- + +## Task 4: Process Control Commands (start, stop, restart, scale, reload-config) + +**Files:** +- Create: `cmd/cbox-init/start_cmd.go` +- Create: `cmd/cbox-init/stop_cmd.go` +- Create: `cmd/cbox-init/restart.go` +- Create: `cmd/cbox-init/scale.go` +- Create: `cmd/cbox-init/reload_config.go` +- Modify: `cmd/cbox-init/root.go` + +- [ ] **Step 1: Create `cmd/cbox-init/start_cmd.go`** + +(Named `start_cmd.go` to avoid conflict with Go's `start` identifier) + +```go +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var startProcessCmd = &cobra.Command{ + Use: "start ", + Short: "Start a stopped process", + Args: cobra.ExactArgs(1), + Run: runStartProcess, +} + +var startURL string + +func init() { + startProcessCmd.Flags().StringVar(&startURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runStartProcess(cmd *cobra.Command, args []string) { + name := args[0] + client := newClient(startURL) + + if err := client.StartProcess(name); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to start %s: %v\n", name, err) + os.Exit(1) + } + + fmt.Printf("āœ“ %s started\n", name) +} +``` + +- [ ] **Step 2: Create `cmd/cbox-init/stop_cmd.go`** + +```go +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var stopProcessCmd = &cobra.Command{ + Use: "stop ", + Short: "Stop a running process", + Args: cobra.ExactArgs(1), + Run: runStopProcess, +} + +var stopURL string + +func init() { + stopProcessCmd.Flags().StringVar(&stopURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runStopProcess(cmd *cobra.Command, args []string) { + name := args[0] + client := newClient(stopURL) + + if err := client.StopProcess(name); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to stop %s: %v\n", name, err) + os.Exit(1) + } + + fmt.Printf("āœ“ %s stopped\n", name) +} +``` + +- [ ] **Step 3: Create `cmd/cbox-init/restart.go`** + +```go +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var restartCmd = &cobra.Command{ + Use: "restart ", + Short: "Restart a process", + Args: cobra.ExactArgs(1), + Run: runRestart, +} + +var restartURL string + +func init() { + restartCmd.Flags().StringVar(&restartURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runRestart(cmd *cobra.Command, args []string) { + name := args[0] + client := newClient(restartURL) + + if err := client.RestartProcess(name); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to restart %s: %v\n", name, err) + os.Exit(1) + } + + fmt.Printf("āœ“ %s restarted\n", name) +} +``` + +- [ ] **Step 4: Create `cmd/cbox-init/scale.go`** + +```go +package main + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" +) + +var scaleCmd = &cobra.Command{ + Use: "scale ", + Short: "Scale a process to the specified number of instances", + Long: `Scale a process to the specified number of instances. + +Examples: + cbox-init scale queue-default 10 # Scale to 10 workers + cbox-init scale horizon 1 # Scale back to 1`, + Args: cobra.ExactArgs(2), + Run: runScale, +} + +var scaleURL string + +func init() { + scaleCmd.Flags().StringVar(&scaleURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runScale(cmd *cobra.Command, args []string) { + name := args[0] + count, err := strconv.Atoi(args[1]) + if err != nil || count < 0 { + fmt.Fprintf(os.Stderr, "āŒ Invalid scale count: %s (must be a non-negative integer)\n", args[1]) + os.Exit(1) + } + + client := newClient(scaleURL) + + if err := client.ScaleProcess(name, count); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to scale %s: %v\n", name, err) + os.Exit(1) + } + + fmt.Printf("āœ“ %s scaled to %d instances\n", name, count) +} +``` + +- [ ] **Step 5: Create `cmd/cbox-init/reload_config.go`** + +```go +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var reloadConfigCmd = &cobra.Command{ + Use: "reload-config", + Short: "Reload configuration from disk", + Long: `Reload the configuration file from disk without restarting the daemon. Processes will be updated according to the new configuration.`, + Args: cobra.NoArgs, + Run: runReloadConfig, +} + +var reloadConfigURL string + +func init() { + reloadConfigCmd.Flags().StringVar(&reloadConfigURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runReloadConfig(cmd *cobra.Command, args []string) { + client := newClient(reloadConfigURL) + + if err := client.ReloadConfig(); err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to reload config: %v\n", err) + os.Exit(1) + } + + fmt.Println("āœ“ Configuration reloaded") +} +``` + +- [ ] **Step 6: Register all commands in `cmd/cbox-init/root.go`** + +Replace the commented-out future commands section (lines 64-69) with: + +```go + // Process control commands + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(startProcessCmd) + rootCmd.AddCommand(stopProcessCmd) + rootCmd.AddCommand(restartCmd) + rootCmd.AddCommand(scaleCmd) + rootCmd.AddCommand(reloadConfigCmd) +``` + +Also update the help examples in rootCmd Long string (line 34-36) to reflect actual commands: + +```go +Examples: + cbox-init serve # Start daemon + cbox-init tui # Interactive dashboard + cbox-init list # List all processes + cbox-init status nginx # Show process details + cbox-init restart horizon # Restart horizon + cbox-init scale queue-default 10 # Scale to 10 workers + cbox-init logs nginx -f # Stream nginx logs`, +``` + +- [ ] **Step 7: Build and verify** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && make build` +Expected: Build succeeds + +- [ ] **Step 8: Verify help output** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && ./build/cbox-init --help` +Expected: Shows all new commands (list, status, start, stop, restart, scale, reload-config) + +- [ ] **Step 9: Commit** + +```bash +git add cmd/cbox-init/start_cmd.go cmd/cbox-init/stop_cmd.go cmd/cbox-init/restart.go cmd/cbox-init/scale.go cmd/cbox-init/reload_config.go cmd/cbox-init/root.go +git commit -m "feat: add start, stop, restart, scale, reload-config CLI commands" +``` + +--- + +## Task 5: Log Subscriber (Pub/Sub for SSE) + +Add a broadcast mechanism to the logger so the API can subscribe to real-time log entries. + +**Files:** +- Create: `internal/logger/subscriber.go` +- Create: `internal/logger/subscriber_test.go` +- Modify: `internal/logger/log_buffer.go` +- Modify: `internal/logger/process_writer.go` + +- [ ] **Step 1: Write failing test for LogBroadcaster** + +Create `internal/logger/subscriber_test.go`: + +```go +package logger + +import ( + "testing" + "time" +) + +func TestLogBroadcaster_SubscribeReceivesEntries(t *testing.T) { + b := NewLogBroadcaster() + + ch, unsub := b.Subscribe("") + defer unsub() + + entry := LogEntry{ + Timestamp: time.Now(), + ProcessName: "nginx", + InstanceID: "nginx-0", + Stream: "stdout", + Message: "hello", + Level: "info", + } + + b.Broadcast(entry) + + select { + case got := <-ch: + if got.Message != "hello" || got.ProcessName != "nginx" { + t.Errorf("unexpected entry: %+v", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for entry") + } +} + +func TestLogBroadcaster_FilterByProcess(t *testing.T) { + b := NewLogBroadcaster() + + ch, unsub := b.Subscribe("nginx") + defer unsub() + + // Send entry for different process — should NOT be received + b.Broadcast(LogEntry{ProcessName: "php-fpm", Message: "wrong"}) + + // Send entry for matching process — should be received + b.Broadcast(LogEntry{ProcessName: "nginx", Message: "correct"}) + + select { + case got := <-ch: + if got.Message != "correct" { + t.Errorf("expected 'correct', got %q", got.Message) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for filtered entry") + } +} + +func TestLogBroadcaster_UnsubscribeStopsDelivery(t *testing.T) { + b := NewLogBroadcaster() + + ch, unsub := b.Subscribe("") + unsub() + + b.Broadcast(LogEntry{Message: "after-unsub"}) + + select { + case _, ok := <-ch: + if ok { + t.Error("received entry after unsubscribe") + } + case <-time.After(100 * time.Millisecond): + // Expected: channel closed, no entry + } +} + +func TestLogBroadcaster_MultipleSubscribers(t *testing.T) { + b := NewLogBroadcaster() + + ch1, unsub1 := b.Subscribe("") + defer unsub1() + ch2, unsub2 := b.Subscribe("") + defer unsub2() + + b.Broadcast(LogEntry{Message: "both"}) + + for _, ch := range []<-chan LogEntry{ch1, ch2} { + select { + case got := <-ch: + if got.Message != "both" { + t.Errorf("unexpected: %q", got.Message) + } + case <-time.After(time.Second): + t.Fatal("timed out") + } + } +} + +func TestLogBroadcaster_SlowSubscriberDropsEntries(t *testing.T) { + b := NewLogBroadcaster() + + ch, unsub := b.Subscribe("") + defer unsub() + + // Fill the channel buffer (256) + more — should not block + for i := 0; i < 300; i++ { + b.Broadcast(LogEntry{Message: "flood"}) + } + + // Drain what we can — we should get at most 256 + count := 0 + for { + select { + case <-ch: + count++ + default: + goto done + } + } +done: + if count > 256 { + t.Errorf("got %d entries, expected at most 256", count) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/logger/ -run TestLogBroadcaster -v -count=1` +Expected: FAIL — `NewLogBroadcaster` undefined + +- [ ] **Step 3: Implement `internal/logger/subscriber.go`** + +```go +package logger + +import ( + "sync" +) + +// LogBroadcaster delivers log entries to multiple subscribers in real-time. +// Subscribers receive entries on a buffered channel. If a subscriber can't +// keep up, entries are dropped (non-blocking send). +type LogBroadcaster struct { + mu sync.RWMutex + subscribers map[uint64]*subscription + nextID uint64 +} + +type subscription struct { + ch chan LogEntry + filter string // process name filter, empty = all +} + +// NewLogBroadcaster creates a new broadcaster. +func NewLogBroadcaster() *LogBroadcaster { + return &LogBroadcaster{ + subscribers: make(map[uint64]*subscription), + } +} + +// Subscribe registers a new subscriber. +// filter is a process name — empty string receives all processes. +// Returns a read channel and an unsubscribe function. +// The channel is closed when unsubscribe is called. +func (b *LogBroadcaster) Subscribe(filter string) (<-chan LogEntry, func()) { + b.mu.Lock() + defer b.mu.Unlock() + + id := b.nextID + b.nextID++ + + ch := make(chan LogEntry, 256) + b.subscribers[id] = &subscription{ch: ch, filter: filter} + + unsub := func() { + b.mu.Lock() + defer b.mu.Unlock() + if sub, ok := b.subscribers[id]; ok { + close(sub.ch) + delete(b.subscribers, id) + } + } + + return ch, unsub +} + +// Broadcast sends a log entry to all matching subscribers. +// Non-blocking: if a subscriber's buffer is full, the entry is dropped. +func (b *LogBroadcaster) Broadcast(entry LogEntry) { + b.mu.RLock() + defer b.mu.RUnlock() + + for _, sub := range b.subscribers { + if sub.filter != "" && sub.filter != entry.ProcessName { + continue + } + // Non-blocking send + select { + case sub.ch <- entry: + default: + // Subscriber too slow, drop entry + } + } +} +``` + +- [ ] **Step 4: Run subscriber tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/logger/ -run TestLogBroadcaster -v -count=1` +Expected: All PASS + +- [ ] **Step 5: Wire broadcaster into `LogBuffer.Add`** + +Modify `internal/logger/log_buffer.go`. Add a broadcaster field and hook it into `Add`: + +Add to the `LogBuffer` struct (after line 24): +```go + broadcaster *LogBroadcaster // optional, for real-time subscribers +``` + +Add a setter method after `Size()`: +```go +// SetBroadcaster attaches a broadcaster for real-time log delivery. +func (lb *LogBuffer) SetBroadcaster(b *LogBroadcaster) { + lb.mu.Lock() + defer lb.mu.Unlock() + lb.broadcaster = b +} +``` + +Modify `Add` (line 41-52) to broadcast after adding: +```go +func (lb *LogBuffer) Add(entry LogEntry) { + lb.mu.Lock() + lb.entries[lb.index] = entry + lb.index++ + if lb.index >= lb.size { + lb.index = 0 + lb.full = true + } + b := lb.broadcaster + lb.mu.Unlock() + + // Broadcast outside the lock to avoid holding it during channel sends + if b != nil { + b.Broadcast(entry) + } +} +``` + +- [ ] **Step 6: Run existing log buffer tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/logger/ -v -count=1` +Expected: All tests PASS (existing + new) + +- [ ] **Step 7: Commit** + +```bash +git add internal/logger/subscriber.go internal/logger/subscriber_test.go internal/logger/log_buffer.go +git commit -m "feat: add log broadcaster for real-time SSE subscription" +``` + +--- + +## Task 6: Wire Broadcaster Through Process Manager + +Connect the broadcaster to all ProcessWriters so every log entry gets broadcast. + +**Files:** +- Modify: `internal/process/manager.go` +- Modify: `internal/process/manager_logs.go` +- Modify: `internal/process/supervisor.go` (where ProcessWriters are created) + +- [ ] **Step 1: Add broadcaster field to Manager** + +In `internal/process/manager.go`, add to the Manager struct (after line 63, the `startTime` field): + +```go + logBroadcaster *logger.LogBroadcaster // Real-time log delivery to API subscribers +``` + +Add import for `logger` if not already present. + +In `NewManager`, initialize it (in the constructor, wherever the struct is created): + +```go + logBroadcaster: logger.NewLogBroadcaster(), +``` + +Add a getter method (can go at end of file or in manager_logs.go): + +```go +// LogBroadcaster returns the broadcaster for real-time log subscriptions. +func (m *Manager) LogBroadcaster() *logger.LogBroadcaster { + return m.logBroadcaster +} +``` + +- [ ] **Step 2: Add `SubscribeLogs` convenience method to `manager_logs.go`** + +Add to `internal/process/manager_logs.go`: + +```go +// SubscribeLogs registers a subscriber for real-time log entries. +// filter is a process name — empty string receives all processes. +// Returns a read channel and an unsubscribe function. +func (m *Manager) SubscribeLogs(filter string) (<-chan logger.LogEntry, func()) { + return m.logBroadcaster.Subscribe(filter) +} +``` + +- [ ] **Step 3: Pass broadcaster to Supervisors** + +In the Supervisor, when creating ProcessWriters in `startInstance()` (in `supervisor.go`), the ProcessWriter's LogBuffer needs the broadcaster attached. The cleanest way: after creating each ProcessWriter, call `SetBroadcaster` on its internal LogBuffer. + +The ProcessWriter's logBuffer is a private field. We need to expose it or pass the broadcaster to `NewProcessWriter`. The simplest approach: add an optional `SetBroadcaster` method on ProcessWriter that delegates to the logBuffer: + +In `internal/logger/process_writer.go`, add after the `Flush` method: + +```go +// SetBroadcaster attaches a broadcaster to the internal log buffer. +func (pw *ProcessWriter) SetBroadcaster(b *LogBroadcaster) { + if pw.logBuffer != nil { + pw.logBuffer.SetBroadcaster(b) + } +} +``` + +Then in the Supervisor's `startInstance` method (in `supervisor.go`), right after creating the stdout/stderr ProcessWriters (the `logger.NewProcessWriter` calls), add: + +```go + if s.logBroadcaster != nil { + stdoutWriter.SetBroadcaster(s.logBroadcaster) + stderrWriter.SetBroadcaster(s.logBroadcaster) + } +``` + +The Supervisor needs the broadcaster. Add a field to the Supervisor struct: + +```go + logBroadcaster *logger.LogBroadcaster +``` + +Add a setter on Supervisor (avoids changing the NewSupervisor signature): + +```go +func (s *Supervisor) SetLogBroadcaster(b *logger.LogBroadcaster) { + s.logBroadcaster = b +} +``` + +Then in the Manager, after each supervisor is created (search for `NewSupervisor` calls in `manager.go` — they're in the `Start` method loop where supervisors are created from config), call: + +```go +sup.SetLogBroadcaster(m.logBroadcaster) +``` + +Also do the same in any code path that creates supervisors dynamically (e.g., `handleAddProcess` in the API, or `AddProcess` on the Manager if it exists). + +- [ ] **Step 4: Build and run tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/process/ ./internal/logger/ -v -count=1` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/process/manager.go internal/process/manager_logs.go internal/process/supervisor.go internal/logger/process_writer.go +git commit -m "feat: wire log broadcaster through process manager to all supervisors" +``` + +--- + +## Task 7: SSE Log Stream API Endpoint + +Add the Server-Sent Events endpoint to the API server. + +**Files:** +- Modify: `internal/api/server.go` + +- [ ] **Step 1: Add SSE route registration** + +In `Start()` method (after line 262, the existing `/api/v1/logs` route), add: + +```go + mux.HandleFunc("/api/v1/logs/stream", s.wrapHandler(s.handleLogStream, true)) +``` + +Also add it to `StartSocketOnly()`: + +```go + mux.HandleFunc("/api/v1/logs/stream", s.wrapHandler(s.handleLogStream, false)) +``` + +- [ ] **Step 2: Implement `handleLogStream` SSE handler** + +Add the handler to `server.go`: + +```go +// handleLogStream provides a Server-Sent Events stream of real-time log entries. +// Query params: +// - process: filter by process name (optional, empty = all) +func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + processFilter := r.URL.Query().Get("process") + + ch, unsub := s.manager.SubscribeLogs(processFilter) + defer unsub() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering + flusher.Flush() + + // Heartbeat ticker to detect dead connections + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-r.Context().Done(): + return + case entry, ok := <-ch: + if !ok { + return // Channel closed + } + data, err := json.Marshal(map[string]interface{}{ + "timestamp": entry.Timestamp.Format(time.RFC3339Nano), + "process": entry.ProcessName, + "instance": entry.InstanceID, + "stream": entry.Stream, + "level": entry.Level, + "message": entry.Message, + }) + if err != nil { + s.logger.Error("Failed to marshal log entry", "error", err) + continue + } + fmt.Fprintf(w, "event: log\ndata: %s\n\n", data) + flusher.Flush() + case <-heartbeat.C: + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + } + } +} +``` + +Add `"encoding/json"` and `"time"` to the imports if not already present (they likely already are in server.go). + +- [ ] **Step 3: Increase WriteTimeout for SSE connections** + +The current `WriteTimeout: 10 * time.Second` on the server will kill SSE connections. The SSE handler needs a longer timeout. The cleanest approach: set `WriteTimeout: 0` (disabled) on the socket server since SSE needs indefinite write, and rely on heartbeat + context cancellation for cleanup. For the TCP server, keep the existing timeout but override it per-request in the SSE handler by using `http.ResponseController`: + +In `handleLogStream`, before the main loop, add: + +```go + // Disable write deadline for this SSE connection + rc := http.NewResponseController(w) + if err := rc.SetWriteDeadline(time.Time{}); err != nil { + s.logger.Warn("Failed to disable write deadline for SSE", "error", err) + } +``` + +- [ ] **Step 4: Build and verify** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && make build` +Expected: Build succeeds + +- [ ] **Step 5: Run API tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/api/ -v -count=1` +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/api/server.go +git commit -m "feat: add SSE log stream endpoint at /api/v1/logs/stream" +``` + +--- + +## Task 8: SSE Client (StreamLogs) + +Add the client-side SSE parsing for `cbox-init logs -f`. + +**Files:** +- Create: `internal/apiclient/stream.go` +- Create: `internal/apiclient/stream_test.go` + +- [ ] **Step 1: Write failing test for StreamLogs** + +Create `internal/apiclient/stream_test.go`: + +```go +package apiclient + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cboxdk/init/internal/logger" +) + +func TestStreamLogs_ReceivesEntries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/logs/stream" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer is not a flusher") + } + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + + // Send two events + fmt.Fprintf(w, "event: log\ndata: {\"timestamp\":\"2026-04-17T10:00:00Z\",\"process\":\"nginx\",\"level\":\"info\",\"message\":\"hello\"}\n\n") + flusher.Flush() + + fmt.Fprintf(w, "event: log\ndata: {\"timestamp\":\"2026-04-17T10:00:01Z\",\"process\":\"nginx\",\"level\":\"info\",\"message\":\"world\"}\n\n") + flusher.Flush() + + // Keep connection open until client disconnects + <-r.Context().Done() + })) + defer server.Close() + + client := New(server.URL, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch, err := client.StreamLogs(ctx, "") + if err != nil { + t.Fatalf("StreamLogs error: %v", err) + } + + // Should receive two entries + entry1 := <-ch + if entry1.Message != "hello" || entry1.ProcessName != "nginx" { + t.Errorf("unexpected first entry: %+v", entry1) + } + + entry2 := <-ch + if entry2.Message != "world" { + t.Errorf("unexpected second entry: %+v", entry2) + } +} + +func TestStreamLogs_WithProcessFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + process := r.URL.Query().Get("process") + if process != "nginx" { + t.Errorf("expected process=nginx, got %q", process) + } + + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: log\ndata: {\"process\":\"nginx\",\"message\":\"filtered\"}\n\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer server.Close() + + client := New(server.URL, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch, err := client.StreamLogs(ctx, "nginx") + if err != nil { + t.Fatalf("StreamLogs error: %v", err) + } + + entry := <-ch + if entry.Message != "filtered" { + t.Errorf("unexpected: %+v", entry) + } +} + +func TestStreamLogs_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer server.Close() + + client := New(server.URL, "") + + ctx, cancel := context.WithCancel(context.Background()) + + ch, err := client.StreamLogs(ctx, "") + if err != nil { + t.Fatalf("StreamLogs error: %v", err) + } + + // Cancel context + cancel() + + // Channel should close + select { + case _, ok := <-ch: + if ok { + // Might get a zero value, that's fine + } + case <-time.After(2 * time.Second): + t.Fatal("channel not closed after context cancel") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/apiclient/ -run TestStream -v -count=1` +Expected: FAIL — `StreamLogs` undefined + +- [ ] **Step 3: Implement `internal/apiclient/stream.go`** + +```go +package apiclient + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/cboxdk/init/internal/logger" +) + +// StreamLogs connects to the SSE log stream and returns a channel of log entries. +// The channel is closed when the context is cancelled or the connection drops. +// If process is non-empty, only logs from that process are streamed. +func (c *Client) StreamLogs(ctx context.Context, process string) (<-chan logger.LogEntry, error) { + path := "/api/v1/logs/stream" + if process != "" { + path = fmt.Sprintf("%s?process=%s", path, process) + } + + req, err := http.NewRequestWithContext(ctx, "GET", c.getURL(path), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "text/event-stream") + if c.auth != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth)) + } + + // Use a client without timeout for streaming + streamClient := *c.client + streamClient.Timeout = 0 + + resp, err := streamClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to connect to log stream: %w", err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("log stream returned status %d", resp.StatusCode) + } + + ch := make(chan logger.LogEntry, 256) + + go func() { + defer close(ch) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + + // Skip comments (keepalive) and empty lines + if line == "" || strings.HasPrefix(line, ":") { + continue + } + + // Parse SSE data line + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + var entry struct { + Timestamp string `json:"timestamp"` + Process string `json:"process"` + Instance string `json:"instance"` + Stream string `json:"stream"` + Level string `json:"level"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(data), &entry); err != nil { + continue // Skip malformed entries + } + + ts, _ := time.Parse(time.RFC3339Nano, entry.Timestamp) + + logEntry := logger.LogEntry{ + Timestamp: ts, + ProcessName: entry.Process, + InstanceID: entry.Instance, + Stream: entry.Stream, + Level: entry.Level, + Message: entry.Message, + } + + select { + case ch <- logEntry: + case <-ctx.Done(): + return + } + } + + // Skip "event:" lines — we only care about data + } + }() + + return ch, nil +} +``` + +- [ ] **Step 4: Run stream tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/apiclient/ -run TestStream -v -count=1` +Expected: All PASS + +- [ ] **Step 5: Run full apiclient tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/apiclient/ -v -count=1` +Expected: All PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/apiclient/stream.go internal/apiclient/stream_test.go +git commit -m "feat: add SSE log stream client for cbox-init logs -f" +``` + +--- + +## Task 9: Rewrite `logs` Command + +Replace the existing logs command (which creates its own process manager) with one that uses the API client. + +**Files:** +- Modify: `cmd/cbox-init/logs.go` + +- [ ] **Step 1: Rewrite `cmd/cbox-init/logs.go`** + +Replace the entire file: + +```go +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "time" + + "github.com/cboxdk/init/internal/apiclient" + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs [process]", + Short: "Tail logs from a process or all processes", + Long: `Tail logs from one or all processes via the daemon API. + +Examples: + cbox-init logs # All processes, last 100 lines + cbox-init logs nginx # Specific process + cbox-init logs nginx --tail 50 # Last 50 lines + cbox-init logs -f # Stream all processes + cbox-init logs nginx -f # Stream specific process + cbox-init logs nginx --tail 20 -f # Last 20 lines then stream`, + Args: cobra.MaximumNArgs(1), + Run: runLogs, +} + +var ( + logsTail int + logsFollow bool + logsLevel string + logsURL string +) + +func init() { + logsCmd.Flags().IntVar(&logsTail, "tail", 100, "Number of lines to show") + logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Stream new log entries") + logsCmd.Flags().StringVar(&logsLevel, "level", "all", "Filter by log level (debug|info|warn|error|all)") + logsCmd.Flags().StringVar(&logsURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runLogs(cmd *cobra.Command, args []string) { + var processName string + if len(args) > 0 { + processName = args[0] + } + + client := newClient(logsURL) + + // Fetch historical logs + var logs []apiclient.LogEntry + var err error + if processName != "" { + logs, err = client.GetLogs(processName, logsTail) + } else { + logs, err = client.GetStackLogs(logsTail) + } + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to fetch logs: %v\n", err) + os.Exit(1) + } + + // Print historical logs + for _, entry := range logs { + printLogEntry(entry) + } + + // If not following, we're done + if !logsFollow { + return + } + + // Stream new entries via SSE + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + ch, err := client.StreamLogs(ctx, processName) + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to connect to log stream: %v\n", err) + os.Exit(1) + } + + for entry := range ch { + printLogEntry(entry) + } +} + +func printLogEntry(entry apiclient.LogEntry) { + ts := entry.Timestamp.Format("15:04:05.000") + fmt.Printf("%s [%s] %s: %s\n", ts, entry.Level, entry.ProcessName, entry.Message) +} +``` + +Wait — the `LogEntry` type is in `logger` package, not `apiclient`. The client methods return `logger.LogEntry`. So we need to use `logger.LogEntry` in the logs command. Let me fix: + +Actually, the `GetLogs` and `GetStackLogs` methods on `Client` return `[]logger.LogEntry`. So the import should use `logger.LogEntry`. Let me update: + +```go +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/cboxdk/init/internal/logger" + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs [process]", + Short: "Tail logs from a process or all processes", + Long: `Tail logs from one or all processes via the daemon API. + +Examples: + cbox-init logs # All processes, last 100 lines + cbox-init logs nginx # Specific process + cbox-init logs nginx --tail 50 # Last 50 lines + cbox-init logs -f # Stream all processes + cbox-init logs nginx -f # Stream specific process + cbox-init logs nginx --tail 20 -f # Last 20 lines then stream`, + Args: cobra.MaximumNArgs(1), + Run: runLogs, +} + +var ( + logsTail int + logsFollow bool + logsLevel string + logsURL string +) + +func init() { + logsCmd.Flags().IntVar(&logsTail, "tail", 100, "Number of lines to show") + logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Stream new log entries") + logsCmd.Flags().StringVar(&logsLevel, "level", "all", "Filter by log level (debug|info|warn|error|all)") + logsCmd.Flags().StringVar(&logsURL, "url", "", "API endpoint (auto-discovers Unix socket by default)") +} + +func runLogs(cmd *cobra.Command, args []string) { + var processName string + if len(args) > 0 { + processName = args[0] + } + + client := newClient(logsURL) + + // Fetch historical logs + var logs []logger.LogEntry + var err error + if processName != "" { + logs, err = client.GetLogs(processName, logsTail) + } else { + logs, err = client.GetStackLogs(logsTail) + } + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to fetch logs: %v\n", err) + os.Exit(1) + } + + // Print historical logs + for _, entry := range logs { + printLogEntry(entry) + } + + // If not following, we're done + if !logsFollow { + return + } + + // Stream new entries via SSE + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + ch, err := client.StreamLogs(ctx, processName) + if err != nil { + fmt.Fprintf(os.Stderr, "āŒ Failed to connect to log stream: %v\n", err) + os.Exit(1) + } + + for entry := range ch { + 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) +} +``` + +- [ ] **Step 2: Build and verify** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && make build` +Expected: Build succeeds + +- [ ] **Step 3: Verify help output** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && ./build/cbox-init logs --help` +Expected: Shows new flags (--tail, -f, --url, --level) + +- [ ] **Step 4: Commit** + +```bash +git add cmd/cbox-init/logs.go +git commit -m "feat: rewrite logs command to use API client with SSE streaming" +``` + +--- + +## Task 10: Full Test Suite + Final Verification + +Run everything and fix any issues. + +**Files:** None (verification only) + +- [ ] **Step 1: Run full test suite** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./... -count=1 -race` +Expected: All tests PASS with race detector + +- [ ] **Step 2: Build all platforms** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && make build` +Expected: Build succeeds + +- [ ] **Step 3: Verify full CLI help** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && ./build/cbox-init --help` +Expected: All commands listed: serve, tui, list, status, start, stop, restart, scale, reload-config, logs, check-config, scaffold, version + +- [ ] **Step 4: Verify individual command help** + +Run each: +```bash +./build/cbox-init list --help +./build/cbox-init status --help +./build/cbox-init start --help +./build/cbox-init stop --help +./build/cbox-init restart --help +./build/cbox-init scale --help +./build/cbox-init reload-config --help +./build/cbox-init logs --help +``` +Expected: Each shows correct usage, flags, and descriptions + +- [ ] **Step 5: Final commit** + +If any fixes were needed, commit them: +```bash +git add -A +git commit -m "fix: address test and build issues from CLI commands implementation" +``` diff --git a/docs/superpowers/plans/2026-04-17-log-file-tailing.md b/docs/superpowers/plans/2026-04-17-log-file-tailing.md new file mode 100644 index 0000000..24a32b3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-log-file-tailing.md @@ -0,0 +1,1162 @@ +# Log File Tailing — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to configure local log files on processes so cbox-init tails them through the same logging pipeline (redaction, JSON parsing, level detection, filtering, broadcasting) as process stdout/stderr. + +**Architecture:** New `internal/logtail/` package with `FileTailer` (pure Go tail -F using fsnotify) and `FileRotator` (size-based rotation). Config extends `LoggingConfig` with a `Files` map. Supervisor creates and manages FileTailers alongside process instances, with config-bound lifecycle (tailers outlive process restarts). + +**Tech Stack:** Go 1.24, fsnotify (already in go.mod), existing logger.ProcessWriter pipeline + +**Spec:** `docs/superpowers/specs/2026-04-17-log-file-tailing-design.md` + +--- + +## File Structure + +### New files +| File | Responsibility | +|------|---------------| +| `internal/logtail/rotator.go` | `FileRotator` — size-based log rotation | +| `internal/logtail/rotator_test.go` | Rotator tests | +| `internal/logtail/tailer.go` | `FileTailer` — pure Go tail -F with fsnotify | +| `internal/logtail/tailer_test.go` | Tailer tests: follow, truncation, replacement, missing file | + +### Modified files +| File | Change | +|------|--------| +| `internal/config/types.go` | Add `Files` field to `LoggingConfig`, new `LogFileConfig` + `RotateConfig` structs | +| `internal/config/validation.go` | Validate log file config in `validateProcessLoggingConfig` | +| `internal/config/types.go` | Add defaults for log file multiline/level detection in `setProcessLoggingAdvancedDefaults` | +| `internal/process/supervisor.go` | Create/manage FileTailers in Start/Stop lifecycle | + +--- + +## Task 1: Config Structs + +Add the configuration types for log file tailing. + +**Files:** +- Modify: `internal/config/types.go` + +- [ ] **Step 1: Add `LogFileConfig` and `RotateConfig` structs** + +Add after `FilterConfig` (after line 193) in `internal/config/types.go`: + +```go +// LogFileConfig configures tailing of a local log file +type LogFileConfig struct { + Path string `yaml:"path" json:"path"` + Rotate *RotateConfig `yaml:"rotate" json:"rotate"` + MinLevel string `yaml:"min_level" json:"min_level"` + JSON *JSONConfig `yaml:"json" json:"json"` + LevelDetection *LevelDetectionConfig `yaml:"level_detection" json:"level_detection"` + Multiline *MultilineConfig `yaml:"multiline" json:"multiline"` + Filters *FilterConfig `yaml:"filters" json:"filters"` +} + +// RotateConfig configures size-based log file rotation +type RotateConfig struct { + MaxSize string `yaml:"max_size" json:"max_size"` // Human-readable size: "50MB", "100KB", "1GB" + MaxFiles int `yaml:"max_files" json:"max_files"` // Number of rotated files to keep +} +``` + +- [ ] **Step 2: Add `Files` field to `LoggingConfig`** + +Add to the `LoggingConfig` struct (after `Filters` on line 149): + +```go + Files map[string]*LogFileConfig `yaml:"files" json:"files"` // Log file tailing +``` + +- [ ] **Step 3: Add `ParseSize` helper for human-readable size strings** + +Add after the new structs: + +```go +// ParseSize parses a human-readable size string (e.g., "50MB", "100KB", "1GB") into bytes. +func ParseSize(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty size string") + } + + s = strings.ToUpper(s) + + multipliers := []struct { + suffix string + mult int64 + }{ + {"GB", 1024 * 1024 * 1024}, + {"MB", 1024 * 1024}, + {"KB", 1024}, + {"B", 1}, + } + + for _, m := range multipliers { + if strings.HasSuffix(s, m.suffix) { + numStr := strings.TrimSuffix(s, m.suffix) + numStr = strings.TrimSpace(numStr) + val, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, fmt.Errorf("invalid size number %q: %w", numStr, err) + } + if val <= 0 { + return 0, fmt.Errorf("size must be positive, got %s", s) + } + return int64(val * float64(m.mult)), nil + } + } + + return 0, fmt.Errorf("invalid size format %q (use KB, MB, or GB suffix)", s) +} +``` + +Add `"strconv"` and `"strings"` to imports if not already present. + +- [ ] **Step 4: Build to verify** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go build ./...` +Expected: Build succeeds + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/types.go +git commit -m "feat: add log file tailing config structs (LogFileConfig, RotateConfig)" +``` + +--- + +## Task 2: Config Validation and Defaults + +Validate log file configuration and set defaults. + +**Files:** +- Modify: `internal/config/validation.go` +- Modify: `internal/config/types.go` (defaults section) + +- [ ] **Step 1: Add log file validation to `validateProcessLoggingConfig`** + +In `internal/config/validation.go`, expand `validateProcessLoggingConfig` (currently lines 440-448). After the existing stdout/stderr check, add: + +```go + // Validate log file tailing configuration + if proc.Logging.Files != nil { + for fileName, fileCfg := range proc.Logging.Files { + if fileCfg.Path == "" { + result.AddProcessError(name, fmt.Sprintf("logging.files.%s.path", fileName), "Path is required", "Specify the file path to tail") + } + if fileCfg.Rotate != nil { + if fileCfg.Rotate.MaxSize != "" { + if _, err := ParseSize(fileCfg.Rotate.MaxSize); err != nil { + result.AddProcessError(name, fmt.Sprintf("logging.files.%s.rotate.max_size", fileName), fmt.Sprintf("Invalid size: %v", err), "Use format like '50MB', '1GB'") + } + } + if fileCfg.Rotate.MaxFiles < 0 { + result.AddProcessError(name, fmt.Sprintf("logging.files.%s.rotate.max_files", fileName), "max_files must be non-negative", "Set max_files >= 0") + } + } + } + } +``` + +- [ ] **Step 2: Add log file defaults to `setProcessLoggingAdvancedDefaults`** + +In `internal/config/types.go`, expand `setProcessLoggingAdvancedDefaults` (currently lines 498-516). After the existing MinLevel default, add: + +```go + // Set defaults for log file tailing configs + for _, fileCfg := range proc.Logging.Files { + if fileCfg.Multiline != nil { + if fileCfg.Multiline.MaxLines == 0 { + fileCfg.Multiline.MaxLines = 100 + } + if fileCfg.Multiline.Timeout == 0 { + fileCfg.Multiline.Timeout = 1 + } + } + if fileCfg.LevelDetection != nil && fileCfg.LevelDetection.DefaultLevel == "" { + fileCfg.LevelDetection.DefaultLevel = "info" + } + if fileCfg.Rotate != nil && fileCfg.Rotate.MaxFiles == 0 { + fileCfg.Rotate.MaxFiles = 5 + } + } +``` + +- [ ] **Step 3: Run tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/config/ -v -count=1` +Expected: All PASS + +- [ ] **Step 4: Commit** + +```bash +git add internal/config/validation.go internal/config/types.go +git commit -m "feat: add validation and defaults for log file tailing config" +``` + +--- + +## Task 3: FileRotator + +Implement size-based log rotation. + +**Files:** +- Create: `internal/logtail/rotator.go` +- Create: `internal/logtail/rotator_test.go` + +- [ ] **Step 1: Write failing tests for FileRotator** + +Create `internal/logtail/rotator_test.go`: + +```go +package logtail + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFileRotator_NoRotationNeeded(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + os.WriteFile(path, []byte("small"), 0644) + + r := NewFileRotator(1024, 3) // 1KB max + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // File should still exist unchanged + data, _ := os.ReadFile(path) + if string(data) != "small" { + t.Errorf("file was modified: %q", data) + } +} + +func TestFileRotator_RotatesWhenOverSize(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + + // Write 2KB of data + data := make([]byte, 2048) + for i := range data { + data[i] = 'x' + } + os.WriteFile(path, data, 0644) + + r := NewFileRotator(1024, 3) // 1KB max, keep 3 + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("rotation failed: %v", err) + } + + // Original file should be truncated + info, err := os.Stat(path) + if err != nil { + t.Fatalf("original file missing: %v", err) + } + if info.Size() != 0 { + t.Errorf("expected truncated file, got size %d", info.Size()) + } + + // .1 file should exist with original data + rotated := path + ".1" + rData, err := os.ReadFile(rotated) + if err != nil { + t.Fatalf("rotated file missing: %v", err) + } + if len(rData) != 2048 { + t.Errorf("rotated file size %d, expected 2048", len(rData)) + } +} + +func TestFileRotator_ShiftsExistingFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + + // Create existing rotated files + os.WriteFile(path+".1", []byte("old-1"), 0644) + os.WriteFile(path+".2", []byte("old-2"), 0644) + + // Write oversized main file + os.WriteFile(path, make([]byte, 2048), 0644) + + r := NewFileRotator(1024, 3) + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("rotation failed: %v", err) + } + + // .1 should have main file data (2048 bytes) + info, _ := os.Stat(path + ".1") + if info.Size() != 2048 { + t.Errorf(".1 size %d, expected 2048", info.Size()) + } + + // .2 should have old .1 data + data, _ := os.ReadFile(path + ".2") + if string(data) != "old-1" { + t.Errorf(".2 content %q, expected 'old-1'", data) + } + + // .3 should have old .2 data + data, _ = os.ReadFile(path + ".3") + if string(data) != "old-2" { + t.Errorf(".3 content %q, expected 'old-2'", data) + } +} + +func TestFileRotator_DeletesBeyondMaxFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + + // Create max rotated files already + os.WriteFile(path+".1", []byte("one"), 0644) + os.WriteFile(path+".2", []byte("two"), 0644) + + // Write oversized main file + os.WriteFile(path, make([]byte, 2048), 0644) + + r := NewFileRotator(1024, 2) // keep only 2 + if err := r.CheckAndRotate(path); err != nil { + t.Fatalf("rotation failed: %v", err) + } + + // .1 and .2 should exist + if _, err := os.Stat(path + ".1"); err != nil { + t.Error(".1 should exist") + } + if _, err := os.Stat(path + ".2"); err != nil { + t.Error(".2 should exist") + } + + // .3 should NOT exist (was old .2, shifted beyond max) + if _, err := os.Stat(path + ".3"); !os.IsNotExist(err) { + t.Error(".3 should have been deleted") + } +} + +func TestFileRotator_MissingFile(t *testing.T) { + r := NewFileRotator(1024, 3) + // Should not error on missing file + if err := r.CheckAndRotate("/tmp/nonexistent-test-file.log"); err != nil { + t.Fatalf("unexpected error on missing file: %v", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/logtail/ -run TestFileRotator -v -count=1` +Expected: FAIL — package not found + +- [ ] **Step 3: Implement `internal/logtail/rotator.go`** + +```go +package logtail + +import ( + "fmt" + "os" +) + +// 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. +type FileRotator struct { + MaxSize int64 + MaxFiles int +} + +// NewFileRotator creates a new FileRotator. +// maxSize is the maximum file size in bytes before rotation. +// maxFiles is the number of rotated files to keep. +func NewFileRotator(maxSize int64, maxFiles int) *FileRotator { + return &FileRotator{ + MaxSize: maxSize, + MaxFiles: maxFiles, + } +} + +// CheckAndRotate checks if the file exceeds MaxSize and rotates if needed. +// Safe to call frequently — returns immediately if file is under the limit. +// Returns nil if file doesn't exist (file may not have been created yet). +func (r *FileRotator) CheckAndRotate(path string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat %s: %w", path, err) + } + + if info.Size() < r.MaxSize { + return nil + } + + // Shift existing rotated files: .N → .N+1, starting from highest + // Delete any that exceed MaxFiles + for i := r.MaxFiles; i >= 1; i-- { + src := fmt.Sprintf("%s.%d", path, i) + dst := fmt.Sprintf("%s.%d", path, i+1) + + if i == r.MaxFiles { + // Delete the oldest file beyond max + os.Remove(dst) + } + + if _, err := os.Stat(src); err == nil { + if i >= r.MaxFiles { + os.Remove(src) + } else { + os.Rename(src, dst) + } + } + } + + // Rename current file to .1 + rotatedPath := fmt.Sprintf("%s.1", path) + if err := os.Rename(path, rotatedPath); err != nil { + return fmt.Errorf("rename %s → %s: %w", path, rotatedPath, err) + } + + // Create new empty file (copytruncate style) + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + f.Close() + + return nil +} +``` + +- [ ] **Step 4: Run rotator tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/logtail/ -run TestFileRotator -v -count=1` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/logtail/ +git commit -m "feat: add FileRotator for size-based log file rotation" +``` + +--- + +## Task 4: FileTailer + +Implement pure Go tail -F with fsnotify. + +**Files:** +- Create: `internal/logtail/tailer.go` +- Create: `internal/logtail/tailer_test.go` + +- [ ] **Step 1: Write failing tests for FileTailer** + +Create `internal/logtail/tailer_test.go`: + +```go +package logtail + +import ( + "bytes" + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// lineCollector is a simple io.Writer that collects lines for testing +type lineCollector struct { + mu sync.Mutex + lines []string + buf bytes.Buffer +} + +func (lc *lineCollector) Write(p []byte) (int, error) { + lc.mu.Lock() + defer lc.mu.Unlock() + lc.buf.Write(p) + for { + line, err := lc.buf.ReadString('\n') + if err != nil { + // Put incomplete line back + lc.buf.WriteString(line) + break + } + lc.lines = append(lc.lines, line[:len(line)-1]) // strip newline + } + return len(p), nil +} + +func (lc *lineCollector) Lines() []string { + lc.mu.Lock() + defer lc.mu.Unlock() + result := make([]string, len(lc.lines)) + copy(result, lc.lines) + return result +} + +func TestFileTailer_FollowNewLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + + // Create empty file + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) // Let tailer initialize + + // Write lines + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("line one\n") + f.WriteString("line two\n") + f.Close() + + // Wait for tailer to pick up + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + if len(lines) < 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "line one" { + t.Errorf("line 0: %q", lines[0]) + } + if lines[1] != "line two" { + t.Errorf("line 1: %q", lines[1]) + } +} + +func TestFileTailer_SeeksToEnd(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + + // Write existing content BEFORE starting tailer + os.WriteFile(path, []byte("old line\n"), 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + // Write new content + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("new line\n") + f.Close() + + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + // Should only see "new line", not "old line" + for _, l := range lines { + if l == "old line" { + t.Error("should not see content that existed before tailer started") + } + } + if len(lines) == 0 || lines[len(lines)-1] != "new line" { + t.Errorf("expected 'new line', got %v", lines) + } +} + +func TestFileTailer_DetectsTruncation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + // Write first line + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("before truncate\n") + f.Close() + time.Sleep(300 * time.Millisecond) + + // Truncate file (simulates copytruncate rotation) + os.Truncate(path, 0) + time.Sleep(200 * time.Millisecond) + + // Write after truncation + f, _ = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("after truncate\n") + f.Close() + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + found := false + for _, l := range lines { + if l == "after truncate" { + found = true + } + } + if !found { + t.Errorf("expected 'after truncate' in lines: %v", lines) + } +} + +func TestFileTailer_WaitsForMissingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "not-yet.log") + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + // Create file after tailer started + f, _ := os.Create(path) + f.WriteString("appeared\n") + f.Close() + + time.Sleep(500 * time.Millisecond) + + lines := collector.Lines() + if len(lines) == 0 || lines[0] != "appeared" { + t.Errorf("expected 'appeared', got %v", lines) + } +} + +func TestFileTailer_StopsOnContextCancel(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + tailer := New(path, collector, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + done <- tailer.Start(ctx) + }() + + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case <-done: + // Expected: Start returned + case <-time.After(2 * time.Second): + t.Fatal("tailer did not stop after context cancel") + } +} + +func TestFileTailer_WithRotator(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.log") + os.WriteFile(path, nil, 0644) + + collector := &lineCollector{} + rotator := NewFileRotator(100, 2) // 100 bytes, keep 2 + tailer := New(path, collector, rotator) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tailer.Start(ctx) + time.Sleep(200 * time.Millisecond) + + // Write enough to trigger rotation + f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + for i := 0; i < 20; i++ { + f.WriteString("this is a log line that is fairly long to fill up space\n") + } + f.Close() + + time.Sleep(500 * time.Millisecond) + + // Rotated file should exist + if _, err := os.Stat(path + ".1"); os.IsNotExist(err) { + t.Error("expected rotated file .1 to exist") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/logtail/ -run TestFileTailer -v -count=1` +Expected: FAIL — `New` function not found + +- [ ] **Step 3: Implement `internal/logtail/tailer.go`** + +```go +package logtail + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/fsnotify/fsnotify" +) + +// FileTailer implements tail -F semantics in pure Go. +// It follows a file, detects truncation and replacement, +// and waits for missing files to appear. +// Output is written to an io.Writer (typically a ProcessWriter). +type FileTailer struct { + path string + writer io.Writer + rotator *FileRotator // optional +} + +// New creates a new FileTailer. +// writer receives complete lines (including newline). +// rotator is optional (nil to disable rotation). +func New(path string, writer io.Writer, rotator *FileRotator) *FileTailer { + return &FileTailer{ + path: path, + writer: writer, + rotator: rotator, + } +} + +// Start begins tailing the file. Blocks until ctx is cancelled. +// If the file doesn't exist, waits for it to appear. +// Detects truncation (seek to start) and replacement (reopen). +func (t *FileTailer) Start(ctx context.Context) error { + // Wait for file to exist + if err := t.waitForFile(ctx); err != nil { + return err + } + + return t.tailLoop(ctx) +} + +// Stop is a no-op — use context cancellation to stop the tailer. +func (t *FileTailer) Stop() error { + return nil +} + +// waitForFile blocks until the target file exists or ctx is cancelled. +// Uses fsnotify to watch the parent directory for file creation. +func (t *FileTailer) waitForFile(ctx context.Context) error { + if _, err := os.Stat(t.path); err == nil { + return nil // File exists + } + + // Watch parent directory for file creation + dir := filepath.Dir(t.path) + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("create watcher: %w", err) + } + defer watcher.Close() + + if err := watcher.Add(dir); err != nil { + // Directory doesn't exist either — fall back to polling + return t.pollForFile(ctx) + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event, ok := <-watcher.Events: + if !ok { + return fmt.Errorf("watcher closed") + } + if event.Name == t.path && (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) { + return nil + } + case err, ok := <-watcher.Errors: + if !ok { + return fmt.Errorf("watcher error channel closed") + } + return fmt.Errorf("watcher error: %w", err) + } + } +} + +// pollForFile polls for file existence as a fallback when fsnotify can't watch the directory. +func (t *FileTailer) pollForFile(ctx context.Context) error { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if _, err := os.Stat(t.path); err == nil { + return nil + } + } + } +} + +// tailLoop is the main tailing loop. Opens the file, seeks to end, +// and reads new lines as they're written. +func (t *FileTailer) tailLoop(ctx context.Context) error { + file, err := os.Open(t.path) + if err != nil { + return fmt.Errorf("open %s: %w", t.path, err) + } + defer file.Close() + + // Seek to end — we only want new content + offset, err := file.Seek(0, io.SeekEnd) + if err != nil { + return fmt.Errorf("seek to end: %w", err) + } + + // Setup fsnotify watcher on the file + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("create watcher: %w", err) + } + defer watcher.Close() + + // Watch both the file and its parent directory (for rename/create detection) + if err := watcher.Add(t.path); err != nil { + return fmt.Errorf("watch file: %w", err) + } + dir := filepath.Dir(t.path) + _ = watcher.Add(dir) // Best-effort, may fail if dir is unreadable + + reader := bufio.NewReader(file) + + for { + select { + case <-ctx.Done(): + return nil + + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + // Check for truncation + info, err := os.Stat(t.path) + if err != nil { + continue + } + + if info.Size() < offset { + // File was truncated — seek to start + file.Seek(0, io.SeekStart) + offset = 0 + reader.Reset(file) + } + + // Read new lines + for { + line, err := reader.ReadString('\n') + if err != nil { + if len(line) > 0 { + // Incomplete line — put it back by seeking back + file.Seek(offset, io.SeekStart) + reader.Reset(file) + } + break + } + offset += int64(len(line)) + // Write to output (ProcessWriter implements io.Writer) + t.writer.Write([]byte(line)) + } + + // Update offset to current position + newOffset, _ := file.Seek(0, io.SeekCurrent) + if newOffset > offset { + offset = newOffset + } + + // Check rotation if configured + if t.rotator != nil { + if err := t.rotator.CheckAndRotate(t.path); err != nil { + // Log but don't fail — rotation is best-effort + continue + } + } + } + + if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + // File was removed/renamed — try to reopen + file.Close() + watcher.Remove(t.path) + + // Wait for file to reappear + if err := t.waitForFile(ctx); err != nil { + return err + } + + file, err = os.Open(t.path) + if err != nil { + return fmt.Errorf("reopen %s: %w", t.path, err) + } + defer file.Close() + + offset = 0 + reader = bufio.NewReader(file) + _ = watcher.Add(t.path) + } + + case _, ok := <-watcher.Errors: + if !ok { + return nil + } + // Continue on watcher errors — best effort + } + } +} +``` + +- [ ] **Step 4: Run all tailer tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./internal/logtail/ -v -count=1` +Expected: All PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/logtail/tailer.go internal/logtail/tailer_test.go +git commit -m "feat: add FileTailer with pure Go tail -F semantics" +``` + +--- + +## Task 5: Supervisor Integration + +Wire FileTailers into the Supervisor lifecycle. + +**Files:** +- Modify: `internal/process/supervisor.go` + +- [ ] **Step 1: Add `fileTailers` field and imports** + +Add to the Supervisor struct (after `logBroadcaster` field, around line 108): + +```go + fileTailers map[string]context.CancelFunc // active file tailers, keyed by config name +``` + +Add import for logtail: +```go + "github.com/cboxdk/init/internal/logtail" +``` + +Also add `"github.com/cboxdk/init/internal/config"` if not already in imports (it likely is via the `config.Process` field). + +- [ ] **Step 2: Add `startFileTailers` method** + +Add after the `startInstance` method: + +```go +// startFileTailers starts file tailers for all configured log files. +// Each tailer gets its own ProcessWriter with the file's logging config +// plus the process-level redaction config. +// Tailers are bound to configuration, not process state — they survive restarts. +func (s *Supervisor) startFileTailers(ctx context.Context) { + if s.config.Logging == nil || len(s.config.Logging.Files) == 0 { + return + } + + if s.fileTailers == nil { + s.fileTailers = make(map[string]context.CancelFunc) + } + + for name, fileCfg := range s.config.Logging.Files { + // Build a LoggingConfig for this file, inheriting process-level redaction + fileLogging := &config.LoggingConfig{ + Redaction: s.config.Logging.Redaction, // Inherited from process + MinLevel: fileCfg.MinLevel, + JSON: fileCfg.JSON, + LevelDetection: fileCfg.LevelDetection, + Multiline: fileCfg.Multiline, + Filters: fileCfg.Filters, + } + + // Create a ProcessWriter for this file + pw, err := logger.NewProcessWriter(s.logger, s.name, name, "file", fileLogging) + if err != nil { + s.logger.Error("Failed to create process writer for log file", + "file", name, "path", fileCfg.Path, "error", err) + continue + } + + // Wire broadcaster for real-time subscriptions + if s.logBroadcaster != nil { + pw.SetBroadcaster(s.logBroadcaster) + } + + // Create optional rotator + var rotator *logtail.FileRotator + if fileCfg.Rotate != nil && fileCfg.Rotate.MaxSize != "" { + maxBytes, err := config.ParseSize(fileCfg.Rotate.MaxSize) + if err != nil { + s.logger.Error("Invalid rotate max_size for log file", + "file", name, "error", err) + continue + } + rotator = logtail.NewFileRotator(maxBytes, fileCfg.Rotate.MaxFiles) + } + + // Create and start tailer + tailer := logtail.New(fileCfg.Path, pw, rotator) + + tailerCtx, tailerCancel := context.WithCancel(ctx) + s.fileTailers[name] = tailerCancel + + s.goroutines.Add(1) + go func(n, p string) { + defer s.goroutines.Done() + if err := tailer.Start(tailerCtx); err != nil && tailerCtx.Err() == nil { + s.logger.Error("File tailer error", "file", n, "path", p, "error", err) + } + }(name, fileCfg.Path) + + s.logger.Info("Started file tailer", "file", name, "path", fileCfg.Path) + } +} + +// stopFileTailers stops all active file tailers. +func (s *Supervisor) stopFileTailers() { + for name, cancel := range s.fileTailers { + cancel() + s.logger.Debug("Stopped file tailer", "file", name) + } + s.fileTailers = nil +} +``` + +- [ ] **Step 3: Hook into Supervisor.Start** + +In the `Start` method (around line 319), after the instance startup loop and before releasing the lock, add a call to start file tailers. Find the line after the instance loop where `s.state` is set to `StateRunning` and add before it: + +```go + // Start file tailers (config-bound, independent of process instances) + s.startFileTailers(s.ctx) +``` + +- [ ] **Step 4: Hook into Supervisor.Stop** + +In the `Stop` method (around line 712), after cancelling the context (the `s.cancel()` call), add: + +```go + // Stop file tailers + s.stopFileTailers() +``` + +- [ ] **Step 5: Build and run tests** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go build ./... && go test ./internal/process/ -v -count=1` +Expected: Build succeeds, all tests PASS + +- [ ] **Step 6: Run full test suite** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./... -count=1` +Expected: All tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add internal/process/supervisor.go +git commit -m "feat: integrate FileTailers into Supervisor lifecycle" +``` + +--- + +## Task 6: Full Verification + +Run everything, verify the feature works end-to-end. + +**Files:** None (verification only) + +- [ ] **Step 1: Run full test suite** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && go test ./... -count=1` +Expected: All tests PASS + +- [ ] **Step 2: Build** + +Run: `cd /home/cortex/.polyscope/clones/c3ce9287/sleek-tiger && make build` +Expected: Build succeeds + +- [ ] **Step 3: Verify config loading with log files** + +Create a temporary test config and run check-config: + +```bash +cat > /tmp/test-logtail.yaml << 'EOF' +version: "1.0" +global: + shutdown_timeout: 30 + log_level: info + +processes: + laravel: + enabled: true + command: ["php-fpm", "-F"] + logging: + redaction: + enabled: true + patterns: + - pattern: "password=\\S+" + replacement: "password=***" + files: + laravel-log: + path: /var/www/html/storage/logs/laravel.log + rotate: + max_size: 50MB + max_files: 7 + json: + enabled: true + detect_auto: true + min_level: info + horizon-log: + path: /var/www/html/storage/logs/horizon.log + json: + enabled: true + detect_auto: true +EOF +./build/cbox-init check-config --config /tmp/test-logtail.yaml +``` +Expected: Config validation passes (may have warnings about missing health check etc., but no errors about log_files) + +- [ ] **Step 4: Clean up and final commit if needed** + +```bash +rm /tmp/test-logtail.yaml +``` + +If any fixes were needed: +```bash +git add -A +git commit -m "fix: address issues from log file tailing verification" +``` diff --git a/docs/superpowers/specs/2026-04-17-cli-commands-and-always-on-socket-design.md b/docs/superpowers/specs/2026-04-17-cli-commands-and-always-on-socket-design.md new file mode 100644 index 0000000..0725ede --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-cli-commands-and-always-on-socket-design.md @@ -0,0 +1,318 @@ +# CLI Commands & Always-On Unix Socket + +**Date:** 2026-04-17 +**Status:** Draft + +## Problem + +1. The TUI and CLI management commands don't work unless `api_enabled: true` and the TCP port is exposed. Users who `docker exec` into a running container expect local management to just work. +2. The root command help text advertises `restart`, `scale`, and other subcommands that don't exist. The API endpoints and process manager methods are fully implemented — only the CLI wrappers are missing. +3. The `logs` command creates its own local process manager instead of connecting to the running daemon. It should use the API and support real-time streaming. + +## Design + +### 1. Always-On Unix Socket + +The daemon always starts a Unix socket listener, regardless of `api_enabled`. The TCP listener remains gated behind `api_enabled` as before. + +**Socket path resolution:** +1. If `api_socket` is set in config, use that path +2. Otherwise, try `/var/run/cbox-init.sock` +3. If `/var/run/` is not writable, fall back to `/tmp/cbox-init.sock` + +**Socket properties:** +- Permissions: `0660` (owner + group read/write) +- Serves the same HTTP handler as the TCP listener +- No ACL middleware (file permissions are the security model) +- No rate limiting (local access only) +- Cleaned up on daemon shutdown (remove socket file) + +**Changes to `serve.go`:** +- Socket listener starts unconditionally before the `if api_enabled` check +- If `api_enabled: true`, TCP listener also starts (existing behavior) +- Both listeners share the same `http.Handler` and process manager reference + +**Changes to `server.go`:** +- New exported method: `StartSocketOnly(socketPath string) error` — starts only the Unix socket listener with the shared handler. Does not start TCP. +- Existing `Start()` method continues to handle both TCP + optional socket as before (used when `api_enabled: true`) +- On startup, removes any stale socket file at the target path before binding (existing behavior in `startSocketListener`, preserving it) + +**Config:** +- No new config fields required +- `api_socket` can still override the default path +- `api_enabled: false` now means "no TCP port" rather than "no API at all" + +### 2. API Client Refactoring + +The API client moves from `internal/tui/client.go` to `internal/apiclient/client.go`. + +**Package:** `internal/apiclient` + +**Constructors:** +```go +// New creates a client with an explicit base URL +func New(baseURL string, authToken string) *Client + +// NewWithAutoDiscover creates a client that auto-detects Unix socket, +// falling back to TCP on localhost:9180 +func NewWithAutoDiscover(authToken string) *Client +``` + +**Auto-discovery order** (unchanged from current TUI client): +1. `/var/run/cbox-init.sock` +2. `/tmp/cbox-init.sock` +3. `/run/cbox-init.sock` +4. Fall back to `http://localhost:9180` + +**Methods:** +```go +// Process management +ListProcesses() ([]ProcessInfo, error) +GetProcessConfig(name string) (*ProcessDetail, error) +StartProcess(name string) error +StopProcess(name string) error +RestartProcess(name string) error +ScaleProcess(name string, desired int) error +ScaleProcessDelta(name string, delta int) error +DeleteProcess(name string) error +UpdateProcess(name string, config interface{}) error +AddProcess(ctx context.Context, name string, cmd []string, scale int, restart string, enabled bool) error + +// Logs +GetLogs(process string, limit int) ([]LogEntry, error) +GetStackLogs(limit int) ([]LogEntry, error) +StreamLogs(ctx context.Context, process string) (<-chan LogEntry, error) + +// Config +ReloadConfig() error +SaveConfig() error + +// Health +HealthCheck(ctx context.Context) error + +// Schedule +PauseSchedule(name string) error +ResumeSchedule(name string) error +TriggerSchedule(name string) error +``` + +**TUI update:** +- `internal/tui/*.go` imports `internal/apiclient` instead of using its own client +- No functional change to TUI behavior + +### 3. CLI Commands + +All commands share a common pattern: +1. Create `apiclient.NewWithAutoDiscover(authToken)` (token from `CBOX_INIT_API_AUTH` env var) +2. Support `--url` flag to override auto-discovery +3. Call the appropriate API method +4. Print result to stdout +5. Exit with code 0 on success, 1 on error + +#### `cbox-init list` + +``` +$ cbox-init list +NAME STATUS SCALE RESTARTS UPTIME +php-fpm running 1/1 0 2h34m +nginx running 1/1 0 2h34m +horizon running 1/1 2 1h12m +queue-default running 3/5 0 2h34m +scheduler stopped 0/1 0 - +``` + +- Table format with color-coded status (green=running, red=stopped, yellow=degraded) +- Exit code 0 if all processes healthy, 1 if any unhealthy +- No arguments required + +#### `cbox-init status ` + +``` +$ cbox-init status horizon +Name: horizon +Status: running +PID: 1234 +Scale: 1/1 +Restarts: 2 +Uptime: 1h12m +Command: php artisan horizon +Health: healthy (last check 5s ago) +``` + +- Detailed view of a single process +- Exit code 1 if process not found + +#### `cbox-init start ` + +``` +$ cbox-init start horizon +āœ“ horizon started +``` + +#### `cbox-init stop ` + +``` +$ cbox-init stop horizon +āœ“ horizon stopped +``` + +#### `cbox-init restart ` + +``` +$ cbox-init restart horizon +āœ“ horizon restarted +``` + +#### `cbox-init scale ` + +``` +$ cbox-init scale queue-default 10 +āœ“ queue-default scaled to 10 instances +``` + +- Takes an absolute count (no relative +N/-N syntax) +- Validates count is a positive integer + +#### `cbox-init reload-config` + +``` +$ cbox-init reload-config +āœ“ Configuration reloaded +``` + +#### `cbox-init logs` (reworked) + +``` +$ cbox-init logs # All processes, last 100 lines +$ cbox-init logs nginx # Specific process, last 100 lines +$ cbox-init logs nginx --tail 50 # Last 50 lines +$ cbox-init logs -f # Follow all processes (SSE) +$ cbox-init logs nginx -f # Follow specific process (SSE) +$ cbox-init logs nginx --tail 20 -f # Last 20 lines then follow +``` + +**Flags:** +- `--tail N` (default 100): Number of historical lines to show +- `-f` / `--follow`: Stream new log entries via SSE after showing history +- `--level`: Filter by log level (existing flag, preserved) + +**Follow mode behavior:** +1. Fetch last `--tail` lines via `GET /api/v1/logs` or `GET /api/v1/processes/{name}/logs` +2. Print them +3. Connect to SSE stream endpoint +4. Print new entries as they arrive +5. Ctrl+C (SIGINT) cancels context and exits cleanly + +### 4. SSE Log Streaming + +#### New API endpoint + +``` +GET /api/v1/logs/stream?process={name} +``` + +- `process` query param is optional. If omitted, streams all processes. +- Response: `Content-Type: text/event-stream` +- Requires auth (same as other endpoints) +- On Unix socket: no auth required (same as other socket requests) + +#### Event format + +``` +event: log +data: {"timestamp":"2026-04-17T10:23:45Z","process":"nginx","level":"info","message":"GET /api/health 200 1ms"} + +: keepalive + +event: log +data: {"timestamp":"2026-04-17T10:23:46Z","process":"horizon","level":"info","message":"Processing job: SendEmail"} +``` + +- Each event has type `log` with a JSON payload +- Heartbeat comment (`: keepalive\n\n`) every 15 seconds to detect dead connections + +#### Server-side implementation + +**Log subscription in process manager:** + +The process manager (in `internal/process/`) exposes a subscriber interface: + +```go +// In internal/process/manager.go or a new manager_logs.go +type LogSubscriber interface { + Subscribe(filter string) (<-chan LogEntry, func()) // channel + unsubscribe func +} +``` + +- `filter` is a process name, or empty string for all processes +- Returns a buffered channel (buffer size ~256) and an unsubscribe function +- Manager broadcasts new log entries to all active subscribers +- Unsubscribe closes the channel and removes the subscriber + +**SSE handler in server.go:** + +```go +func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + // ... + processFilter := r.URL.Query().Get("process") + ch, unsub := s.manager.Subscribe(processFilter) + defer unsub() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Heartbeat ticker + event loop + // Respect r.Context().Done() for clean disconnect +} +``` + +#### Client-side implementation + +`apiclient.StreamLogs()` in `internal/apiclient/stream.go`: + +```go +func (c *Client) StreamLogs(ctx context.Context, process string) (<-chan LogEntry, error) { + // Open HTTP request with Accept: text/event-stream + // Parse SSE events from response body in goroutine + // Send LogEntry on returned channel + // Close channel when ctx is cancelled or connection dies + // Reconnect with backoff on unexpected disconnect +} +``` + +Reconnect strategy: 1s, 2s, 4s, 8s, max 30s backoff. Stops on context cancellation. + +## File Changes + +### Modified files +| File | Change | +|------|--------| +| `cmd/cbox-init/serve.go` | Start socket unconditionally, TCP gated behind `api_enabled` | +| `cmd/cbox-init/root.go` | Register new commands, remove commented-out lines, update help examples | +| `cmd/cbox-init/logs.go` | Rework to use apiclient + SSE streaming | +| `internal/api/server.go` | Socket-only start method, SSE log stream endpoint + handler | +| `internal/tui/*.go` | Update imports from local client to `internal/apiclient` | +| `internal/process/manager.go` | Add log subscriber interface and broadcast mechanism | + +### New files +| File | Purpose | +|------|---------| +| `internal/apiclient/client.go` | Refactored API client (from tui/client.go) | +| `internal/apiclient/stream.go` | SSE parsing and StreamLogs implementation | +| `cmd/cbox-init/list.go` | `list` command | +| `cmd/cbox-init/status.go` | `status ` command | +| `cmd/cbox-init/start.go` | `start ` command | +| `cmd/cbox-init/stop.go` | `stop ` command | +| `cmd/cbox-init/restart.go` | `restart ` command | +| `cmd/cbox-init/scale.go` | `scale ` command | +| `cmd/cbox-init/reload_config.go` | `reload-config` command | + +## Not in Scope + +- No new config fields — socket works without config changes +- No breaking changes — `api_enabled` still controls TCP as before +- TUI functionality unchanged — only import path changes +- No authentication changes — socket skips auth as it does today +- No changes to existing API endpoints — only additions (SSE stream) diff --git a/docs/superpowers/specs/2026-04-17-log-file-tailing-design.md b/docs/superpowers/specs/2026-04-17-log-file-tailing-design.md new file mode 100644 index 0000000..b0bc8ef --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-log-file-tailing-design.md @@ -0,0 +1,180 @@ +# Log File Tailing + +**Date:** 2026-04-17 +**Status:** Draft + +## Problem + +Applications write logs to local files (e.g., Laravel's `storage/logs/laravel.log`) that are invisible to cbox-init. Users want these file-based logs piped through the same logging pipeline as process stdout/stderr — with JSON normalization, level detection, severity filtering, redaction, and real-time streaming via TUI/CLI/SSE. + +## Design + +### 1. Configuration + +Log files are configured under `logging.files` on a process. Each file has its own format/filtering config. Redaction is inherited from the process-level `logging.redaction` — it applies globally to stdout, stderr, and all log files. + +```yaml +processes: + laravel: + command: ["php-fpm", "-F"] + logging: + redaction: # Global — applies to stdout, stderr, AND all files + enabled: true + patterns: + - pattern: "password=\\S+" + replacement: "password=***" + files: + laravel-log: + path: /var/www/html/storage/logs/laravel.log + rotate: + max_size: 50MB + max_files: 7 + json: { auto_detect: true } + level_detection: + patterns: + error: "\\[ERROR\\]" + warn: "\\[WARNING\\]" + min_level: info + horizon-log: + path: /var/www/html/storage/logs/horizon.log + json: { auto_detect: true } +``` + +**Config structs:** + +New field on `LoggingConfig`: +```go +Files map[string]*LogFileConfig `yaml:"files" json:"files"` +``` + +New structs: +```go +type LogFileConfig struct { + Path string `yaml:"path" json:"path"` + Rotate *RotateConfig `yaml:"rotate" json:"rotate"` + MinLevel string `yaml:"min_level" json:"min_level"` + JSON *JSONConfig `yaml:"json" json:"json"` + LevelDetection *LevelDetectionConfig `yaml:"level_detection" json:"level_detection"` + Multiline *MultilineConfig `yaml:"multiline" json:"multiline"` + Filters *FilterConfig `yaml:"filters" json:"filters"` +} + +type RotateConfig struct { + MaxSize string `yaml:"max_size" json:"max_size"` // e.g., "50MB", "100KB", "1GB" + MaxFiles int `yaml:"max_files" json:"max_files"` // Number of rotated files to keep +} +``` + +Per-file fields (`json`, `level_detection`, `min_level`, `multiline`, `filters`) map directly to the corresponding fields in `LoggingConfig`. Redaction is NOT on `LogFileConfig` — it is always inherited from the parent process's `logging.redaction`. + +### 2. FileTailer + +Pure Go implementation of `tail -F` semantics. No shell commands, no external `tail` binary — works in any container. + +**Behavior:** +- **Start:** Open file, seek to end (only new content). If file doesn't exist yet, watch parent directory and wait for it to appear. +- **Follow:** Use `fsnotify` to detect writes. Read new bytes, buffer into lines. +- **Truncation detection:** If file size shrinks (app's log rotation via `copytruncate`), seek to beginning and continue. +- **Replacement detection:** If file is deleted and recreated (rename-based rotation), close old handle, reopen, continue from start of new file. +- **Output:** Feed complete lines into a `ProcessWriter`, which runs the full logging pipeline (redaction, JSON parsing, level detection, filtering, broadcast). + +```go +// Package: internal/logtail + +type FileTailer struct { + path string + writer *logger.ProcessWriter + rotator *FileRotator // Optional, nil if no rotate config + watcher *fsnotify.Watcher +} + +func New(path string, writer *logger.ProcessWriter, rotator *FileRotator) *FileTailer +func (t *FileTailer) Start(ctx context.Context) error // Blocks until ctx cancelled +func (t *FileTailer) Stop() error +``` + +### 3. FileRotator + +Simple size-based log rotation. Runs as part of the tailing loop — checks file size after each read cycle. + +**Behavior:** +- When file reaches `max_size`: rename `app.log` → `app.log.1`, shift `app.log.1` → `app.log.2`, etc. +- Delete files beyond `max_files` count. +- Truncate original file after rename (copytruncate style — the app holds the file handle open). +- The FileTailer detects the truncation and continues seamlessly. + +```go +type FileRotator struct { + maxSize int64 + maxFiles int +} + +func NewFileRotator(maxSize int64, maxFiles int) *FileRotator +func (r *FileRotator) CheckAndRotate(path string) error +``` + +`max_size` parsing: supports human-readable sizes — `"50MB"`, `"100KB"`, `"1GB"`. Parsed during config validation. + +### 4. Integration in Supervisor + +Each configured log file gets its own `ProcessWriter` + `FileTailer`, managed by the Supervisor. + +**ProcessWriter per file:** +- `ProcessName`: the process name (scoping) +- `InstanceID`: the file config key (e.g., `"laravel-log"`) +- `Stream`: `"file"` (distinguishes from `"stdout"`/`"stderr"`) +- `LoggingConfig`: built from file-specific config with redaction inherited from process + +**Lifecycle:** + +FileTailers are bound to **configuration**, not process state: + +1. **Process configured** → FileTailers start, seek to end of each file +2. **Process stop/restart** → FileTailers keep running (capture shutdown logs, maintain file position) +3. **Process removed from config** → FileTailers stop + +This means log entries written during graceful shutdown are captured, and no data is lost during restarts. The FileTailer doesn't need to track or persist file offsets across restarts. + +**Supervisor struct changes:** +```go +// New field: +fileTailers map[string]*logtail.FileTailer // keyed by file config name +``` + +FileTailers are created when the supervisor starts (or when config is reloaded with new files). Each runs in its own goroutine. They share the process's `LogBroadcaster` for real-time streaming. + +### 5. Data Flow + +``` +log file → FileTailer → ProcessWriter → [Redaction → JSON → LevelDetect → Filter] → LogBuffer → Broadcaster + ↓ ↓ + TUI/API SSE stream +``` + +Identical pipeline to stdout/stderr. The only difference is the input source. Log entries from files appear in TUI, `cbox-init logs`, and SSE streaming with `stream=file` and `instance_id=` metadata. + +## File Changes + +### New files +| File | Purpose | +|------|---------| +| `internal/logtail/tailer.go` | FileTailer — pure Go tail -F with fsnotify | +| `internal/logtail/tailer_test.go` | Tests: follow, truncation, replacement, missing file | +| `internal/logtail/rotator.go` | FileRotator — size-based rotation | +| `internal/logtail/rotator_test.go` | Tests: rotation trigger, max_files cleanup, size parsing | + +### Modified files +| File | Change | +|------|--------| +| `internal/config/types.go` | Add `Files` field to `LoggingConfig`, new `LogFileConfig` + `RotateConfig` structs | +| `internal/config/validation.go` | Validate log file config: path required, max_size parsing, max_files > 0 | +| `internal/config/defaults.go` | No default rotation unless explicitly configured | +| `internal/process/supervisor.go` | Create/manage FileTailers alongside process lifecycle | + +## Not in Scope + +- Glob patterns for file paths +- Log rotation scheduling (time-based) — only size-based +- Compression of rotated files +- Tailing files not associated with a process +- Persisting file offsets across daemon restarts From 20714de16cefc6d3a05f45768f602d23ece1ef11 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 16:11:40 +0200 Subject: [PATCH 17/18] fix: address errcheck lint warnings in logtail package --- internal/logtail/rotator.go | 4 ++-- internal/logtail/rotator_test.go | 16 ++++++++-------- internal/logtail/tailer.go | 10 +++++----- internal/logtail/tailer_test.go | 20 ++++++++++---------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/logtail/rotator.go b/internal/logtail/rotator.go index 4858c5b..d9ef097 100644 --- a/internal/logtail/rotator.go +++ b/internal/logtail/rotator.go @@ -45,9 +45,9 @@ func (r *FileRotator) CheckAndRotate(path string) error { if _, err := os.Stat(src); err == nil { if i >= r.MaxFiles { - os.Remove(src) + _ = os.Remove(src) } else { - os.Rename(src, dst) + _ = os.Rename(src, dst) } } } diff --git a/internal/logtail/rotator_test.go b/internal/logtail/rotator_test.go index a825802..2167adb 100644 --- a/internal/logtail/rotator_test.go +++ b/internal/logtail/rotator_test.go @@ -9,7 +9,7 @@ import ( func TestFileRotator_NoRotationNeeded(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "app.log") - os.WriteFile(path, []byte("small"), 0644) + _ = os.WriteFile(path, []byte("small"), 0644) r := NewFileRotator(1024, 3) if err := r.CheckAndRotate(path); err != nil { @@ -24,7 +24,7 @@ func TestFileRotator_NoRotationNeeded(t *testing.T) { func TestFileRotator_RotatesWhenOverSize(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "app.log") - os.WriteFile(path, make([]byte, 2048), 0644) + _ = os.WriteFile(path, make([]byte, 2048), 0644) r := NewFileRotator(1024, 3) if err := r.CheckAndRotate(path); err != nil { @@ -49,9 +49,9 @@ func TestFileRotator_ShiftsExistingFiles(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "app.log") - os.WriteFile(path+".1", []byte("old-1"), 0644) - os.WriteFile(path+".2", []byte("old-2"), 0644) - os.WriteFile(path, make([]byte, 2048), 0644) + _ = os.WriteFile(path+".1", []byte("old-1"), 0644) + _ = os.WriteFile(path+".2", []byte("old-2"), 0644) + _ = os.WriteFile(path, make([]byte, 2048), 0644) r := NewFileRotator(1024, 3) if err := r.CheckAndRotate(path); err != nil { @@ -76,9 +76,9 @@ func TestFileRotator_DeletesBeyondMaxFiles(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "app.log") - os.WriteFile(path+".1", []byte("one"), 0644) - os.WriteFile(path+".2", []byte("two"), 0644) - os.WriteFile(path, make([]byte, 2048), 0644) + _ = os.WriteFile(path+".1", []byte("one"), 0644) + _ = os.WriteFile(path+".2", []byte("two"), 0644) + _ = os.WriteFile(path, make([]byte, 2048), 0644) r := NewFileRotator(1024, 2) if err := r.CheckAndRotate(path); err != nil { diff --git a/internal/logtail/tailer.go b/internal/logtail/tailer.go index fa17b6e..e58a776 100644 --- a/internal/logtail/tailer.go +++ b/internal/logtail/tailer.go @@ -129,13 +129,13 @@ func (t *FileTailer) tailLoop(ctx context.Context, seekToEnd bool) error { if err != nil { if len(line) > 0 { // Incomplete line — seek back so we re-read it next time - file.Seek(offset, io.SeekStart) + _, _ = file.Seek(offset, io.SeekStart) reader.Reset(file) } break } offset += int64(len(line)) - t.writer.Write([]byte(line)) + _, _ = t.writer.Write([]byte(line)) } newOffset, _ := file.Seek(0, io.SeekCurrent) @@ -164,7 +164,7 @@ func (t *FileTailer) tailLoop(ctx context.Context, seekToEnd bool) error { continue } if info.Size() < offset { - file.Seek(0, io.SeekStart) + _, _ = file.Seek(0, io.SeekStart) offset = 0 reader.Reset(file) } @@ -172,13 +172,13 @@ func (t *FileTailer) tailLoop(ctx context.Context, seekToEnd bool) error { readLines() if t.rotator != nil { - t.rotator.CheckAndRotate(t.path) + _ = t.rotator.CheckAndRotate(t.path) } } if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { file.Close() - watcher.Remove(t.path) + _ = watcher.Remove(t.path) if err := t.waitForFile(ctx); err != nil { return err diff --git a/internal/logtail/tailer_test.go b/internal/logtail/tailer_test.go index 2fa4903..db757d0 100644 --- a/internal/logtail/tailer_test.go +++ b/internal/logtail/tailer_test.go @@ -42,7 +42,7 @@ func (lc *lineCollector) Lines() []string { func TestFileTailer_FollowNewLines(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.log") - os.WriteFile(path, nil, 0644) + _ = os.WriteFile(path, nil, 0644) collector := &lineCollector{} tailer := New(path, collector, nil) @@ -50,7 +50,7 @@ func TestFileTailer_FollowNewLines(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go tailer.Start(ctx) + go func() { _ = tailer.Start(ctx) }() time.Sleep(200 * time.Millisecond) f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) @@ -75,7 +75,7 @@ func TestFileTailer_FollowNewLines(t *testing.T) { func TestFileTailer_SeeksToEnd(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.log") - os.WriteFile(path, []byte("old line\n"), 0644) + _ = os.WriteFile(path, []byte("old line\n"), 0644) collector := &lineCollector{} tailer := New(path, collector, nil) @@ -83,7 +83,7 @@ func TestFileTailer_SeeksToEnd(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go tailer.Start(ctx) + go func() { _ = tailer.Start(ctx) }() time.Sleep(200 * time.Millisecond) f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) @@ -106,7 +106,7 @@ func TestFileTailer_SeeksToEnd(t *testing.T) { func TestFileTailer_DetectsTruncation(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.log") - os.WriteFile(path, nil, 0644) + _ = os.WriteFile(path, nil, 0644) collector := &lineCollector{} tailer := New(path, collector, nil) @@ -114,7 +114,7 @@ func TestFileTailer_DetectsTruncation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go tailer.Start(ctx) + go func() { _ = tailer.Start(ctx) }() time.Sleep(200 * time.Millisecond) f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) @@ -152,7 +152,7 @@ func TestFileTailer_WaitsForMissingFile(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go tailer.Start(ctx) + go func() { _ = tailer.Start(ctx) }() time.Sleep(200 * time.Millisecond) f, _ := os.Create(path) @@ -170,7 +170,7 @@ func TestFileTailer_WaitsForMissingFile(t *testing.T) { func TestFileTailer_StopsOnContextCancel(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.log") - os.WriteFile(path, nil, 0644) + _ = os.WriteFile(path, nil, 0644) collector := &lineCollector{} tailer := New(path, collector, nil) @@ -193,7 +193,7 @@ func TestFileTailer_StopsOnContextCancel(t *testing.T) { func TestFileTailer_WithRotator(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.log") - os.WriteFile(path, nil, 0644) + _ = os.WriteFile(path, nil, 0644) collector := &lineCollector{} rotator := NewFileRotator(100, 2) @@ -202,7 +202,7 @@ func TestFileTailer_WithRotator(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go tailer.Start(ctx) + go func() { _ = tailer.Start(ctx) }() time.Sleep(200 * time.Millisecond) f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) From baaa0bc2c507ad16cada0f7f4a2d95fae7d02e28 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 17 Apr 2026 16:19:37 +0200 Subject: [PATCH 18/18] fix: resolve remaining lint warnings (errcheck, staticcheck) --- internal/apiclient/stream_test.go | 6 ++---- internal/logtail/tailer_test.go | 16 ++++++++-------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/internal/apiclient/stream_test.go b/internal/apiclient/stream_test.go index b871e79..a29388e 100644 --- a/internal/apiclient/stream_test.go +++ b/internal/apiclient/stream_test.go @@ -104,10 +104,8 @@ func TestStreamLogs_ContextCancellation(t *testing.T) { cancel() select { - case _, ok := <-ch: - if ok { - // Might get a zero value, that's fine - } + case <-ch: + // Channel delivered or closed — both are acceptable after cancel case <-time.After(2 * time.Second): t.Fatal("channel not closed after context cancel") } diff --git a/internal/logtail/tailer_test.go b/internal/logtail/tailer_test.go index db757d0..2993d56 100644 --- a/internal/logtail/tailer_test.go +++ b/internal/logtail/tailer_test.go @@ -54,8 +54,8 @@ func TestFileTailer_FollowNewLines(t *testing.T) { time.Sleep(200 * time.Millisecond) f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) - f.WriteString("line one\n") - f.WriteString("line two\n") + _, _ = f.WriteString("line one\n") + _, _ = f.WriteString("line two\n") f.Close() time.Sleep(500 * time.Millisecond) @@ -87,7 +87,7 @@ func TestFileTailer_SeeksToEnd(t *testing.T) { time.Sleep(200 * time.Millisecond) f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) - f.WriteString("new line\n") + _, _ = f.WriteString("new line\n") f.Close() time.Sleep(500 * time.Millisecond) @@ -118,15 +118,15 @@ func TestFileTailer_DetectsTruncation(t *testing.T) { time.Sleep(200 * time.Millisecond) f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) - f.WriteString("before truncate\n") + _, _ = f.WriteString("before truncate\n") f.Close() time.Sleep(300 * time.Millisecond) - os.Truncate(path, 0) + _ = os.Truncate(path, 0) time.Sleep(200 * time.Millisecond) f, _ = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) - f.WriteString("after truncate\n") + _, _ = f.WriteString("after truncate\n") f.Close() time.Sleep(500 * time.Millisecond) @@ -156,7 +156,7 @@ func TestFileTailer_WaitsForMissingFile(t *testing.T) { time.Sleep(200 * time.Millisecond) f, _ := os.Create(path) - f.WriteString("appeared\n") + _, _ = f.WriteString("appeared\n") f.Close() time.Sleep(500 * time.Millisecond) @@ -207,7 +207,7 @@ func TestFileTailer_WithRotator(t *testing.T) { f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) for i := 0; i < 20; i++ { - f.WriteString("this is a log line that is fairly long to fill up space\n") + _, _ = f.WriteString("this is a log line that is fairly long to fill up space\n") } f.Close()