From a71219637af1746e9faba5274b6095da676ec555 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 29 May 2026 12:11:26 +0200 Subject: [PATCH] fix: add support for unix socket mode in healthcheck --- backend/internal/cmds/healthcheck.go | 87 ++++++++++++++--------- backend/internal/cmds/healthcheck_test.go | 77 ++++++++++++++++++++ 2 files changed, 132 insertions(+), 32 deletions(-) create mode 100644 backend/internal/cmds/healthcheck_test.go diff --git a/backend/internal/cmds/healthcheck.go b/backend/internal/cmds/healthcheck.go index d32bd1f4..bbea825c 100644 --- a/backend/internal/cmds/healthcheck.go +++ b/backend/internal/cmds/healthcheck.go @@ -2,9 +2,12 @@ package cmds import ( "context" + "fmt" "log/slog" + "net" "net/http" "os" + "strings" "time" "github.com/spf13/cobra" @@ -13,8 +16,14 @@ import ( ) type healthcheckFlags struct { - Endpoint string - Verbose bool + Endpoint string + UnixSocket string + Verbose bool +} + +type healthcheckResult struct { + StatusCode int + URL string } func init() { @@ -29,47 +38,26 @@ func init() { ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) defer cancel() - url := flags.Endpoint + "/healthz" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if flags.UnixSocket == "" && !cmd.Flags().Changed("endpoint") { + flags.UnixSocket = common.EnvConfig.UnixSocket + } + + result, err := healthcheck(ctx, flags) if err != nil { slog.ErrorContext(ctx, - "Failed to create request object", + "Healthcheck failed", "error", err, - "url", url, "ms", time.Since(start).Milliseconds(), ) os.Exit(1) } - res, err := http.DefaultClient.Do(req) - if err != nil { - slog.ErrorContext(ctx, - "Failed to perform request", - "error", err, - "url", url, - "ms", time.Since(start).Milliseconds(), - ) - os.Exit(1) - } - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode >= 300 { - if err != nil { - slog.ErrorContext(ctx, - "Healthcheck failed", - "status", res.StatusCode, - "url", url, - "ms", time.Since(start).Milliseconds(), - ) - os.Exit(1) - } - } - if flags.Verbose { slog.InfoContext(ctx, "Healthcheck succeeded", - "status", res.StatusCode, - "url", url, + "status", result.StatusCode, + "url", result.URL, + "unixSocket", flags.UnixSocket, "ms", time.Since(start).Milliseconds(), ) } @@ -77,7 +65,42 @@ func init() { } healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID") + healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID") healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode") rootCmd.AddCommand(healthcheckCmd) } + +func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) { + url := strings.TrimRight(flags.Endpoint, "/") + "/healthz" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request object for %q: %w", url, err) + } + + client := http.DefaultClient + if flags.UnixSocket != "" { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + dialer := net.Dialer{} + return dialer.DialContext(ctx, "unix", flags.UnixSocket) + } + client = &http.Client{Transport: transport} + } + + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform request to %q: %w", url, err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status %d from %q", res.StatusCode, url) + } + + return &healthcheckResult{ + StatusCode: res.StatusCode, + URL: url, + }, nil +} diff --git a/backend/internal/cmds/healthcheck_test.go b/backend/internal/cmds/healthcheck_test.go new file mode 100644 index 00000000..6898269e --- /dev/null +++ b/backend/internal/cmds/healthcheck_test.go @@ -0,0 +1,77 @@ +package cmds + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestHealthcheckTCPSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/healthz", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + result, err := healthcheck(t.Context(), healthcheckFlags{ + Endpoint: server.URL, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, result.StatusCode) + require.Equal(t, server.URL+"/healthz", result.URL) +} + +func TestHealthcheckFailsOnUnexpectedStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + _, err := healthcheck(t.Context(), healthcheckFlags{ + Endpoint: server.URL, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "unexpected status 500") +} + +func TestHealthcheckUnixSocket(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "pocket-id.sock") + listener, err := net.Listen("unix", socketPath) + require.NoError(t, err) + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/healthz", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }), + } + + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(listener) + }() + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, server.Shutdown(ctx)) + require.ErrorIs(t, <-errCh, http.ErrServerClosed) + }) + + result, err := healthcheck(t.Context(), healthcheckFlags{ + Endpoint: "http://localhost:1411", + UnixSocket: socketPath, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, result.StatusCode) + require.Equal(t, "http://localhost:1411/healthz", result.URL) +}