diff --git a/cmd/hauler/cli/store/serve.go b/cmd/hauler/cli/store/serve.go index 04bf928..5e41f41 100644 --- a/cmd/hauler/cli/store/serve.go +++ b/cmd/hauler/cli/store/serve.go @@ -126,6 +126,18 @@ func ServeRegistryCmd(ctx context.Context, o *flags.ServeRegistryOpts, s *store. return err } + if cfg.HTTP.Debug.Addr != "" { + l.Infof("starting debug server on address [%s]", cfg.HTTP.Debug.Addr) + if cfg.HTTP.Debug.Prometheus.Enabled { + path := cfg.HTTP.Debug.Prometheus.Path + if path == "" { + path = "/metrics" + } + l.Infof("providing prometheus metrics on [%s]", path) + } + } + server.ConfigureDebugServer(cfg) + if err = r.ListenAndServe(); err != nil { return err } diff --git a/go.mod b/go.mod index adbb2d3..3f779db 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/containerd/errdefs v1.0.0 github.com/distribution/distribution/v3 v3.1.1 github.com/distribution/reference v0.6.0 + github.com/docker/go-metrics v0.0.1 github.com/dustin/go-humanize v1.0.1 github.com/google/go-containerregistry v0.21.9 github.com/google/uuid v1.6.0 @@ -132,7 +133,6 @@ require ( github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect - github.com/docker/go-metrics v0.0.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect diff --git a/internal/server/registry.go b/internal/server/registry.go index 8856fd8..c636277 100644 --- a/internal/server/registry.go +++ b/internal/server/registry.go @@ -11,6 +11,7 @@ import ( "github.com/distribution/distribution/v3/configuration" "github.com/distribution/distribution/v3/registry" "github.com/distribution/distribution/v3/registry/handlers" + dockermetrics "github.com/docker/go-metrics" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -24,6 +25,27 @@ func NewRegistry(ctx context.Context, cfg *configuration.Configuration) (*regist return r, nil } +// ConfigureDebugServer starts pprof/expvar/prometheus on cfg.HTTP.Debug.Addr +func ConfigureDebugServer(cfg *configuration.Configuration) { + if cfg.HTTP.Debug.Addr == "" { + return + } + + if cfg.HTTP.Debug.Prometheus.Enabled { + path := cfg.HTTP.Debug.Prometheus.Path + if path == "" { + path = "/metrics" + } + http.Handle(path, dockermetrics.Handler()) + } + + go func(addr string) { + if err := http.ListenAndServe(addr, nil); err != nil { //nolint:gosec // debug interface: internal-only, no read/write timeouts needed + logrus.Fatalf("error listening on debug interface: %v", err) + } + }(cfg.HTTP.Debug.Addr) +} + type tmpRegistryServer struct { *httptest.Server } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4bfceda..cb8daf0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,10 +2,14 @@ package server import ( "context" + "io" + "net" "net/http" "strings" "testing" + "time" + "github.com/distribution/distribution/v3/configuration" // Register the filesystem storage driver for the distribution registry. _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" @@ -16,9 +20,8 @@ func TestNewTempRegistry_StartStop(t *testing.T) { ctx := context.Background() srv := NewTempRegistry(ctx, t.TempDir()) - // Start the httptest server directly to avoid the Start() method's - // retry logic which only accepts HTTP 200, while /v2 returns 401 - // from the distribution registry. + // start the httptest server directly to avoid the retry logic which only accepts HTTP 200 + // while /v2 returns 401 from the distribution registry. srv.Server.Start() t.Cleanup(func() { srv.Stop() }) @@ -74,6 +77,55 @@ func TestNewFile_Configuration(t *testing.T) { } } +// this is the only test in the package allowed to enable prometheus, since it registers +// on http.DefaultServeMux and a second registration would panic. +func TestConfigureDebugServer_Prometheus(t *testing.T) { + // grab a free port and release it so ConfigureDebugServer can bind it + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to reserve a free port: %v", err) + } + addr := l.Addr().String() + l.Close() + + cfg := &configuration.Configuration{} + cfg.HTTP.Debug.Addr = addr + cfg.HTTP.Debug.Prometheus.Enabled = true + cfg.HTTP.Debug.Prometheus.Path = "/metrics" + + ConfigureDebugServer(cfg) + + var resp *http.Response + for i := 0; i < 20; i++ { + resp, err = http.Get("http://" + addr + "/metrics") + if err == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + if err != nil { + t.Fatalf("expected GET /metrics to eventually succeed, got error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200 from the prometheus handler, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + if !strings.Contains(string(body), "go_gc_duration_seconds") { + t.Fatalf("expected prometheus-formatted metrics output, got: %s", body) + } +} + +// an empty Debug.Addr should just no-op, not start a listener. +func TestConfigureDebugServer_NoAddr(t *testing.T) { + ConfigureDebugServer(&configuration.Configuration{}) +} + func TestNewFile_DefaultPort(t *testing.T) { ctx := context.Background() opts := flags.ServeFilesOpts{