diff --git a/.github/e2e.sh b/.github/e2e.sh index 33fe98c2..87b82fbe 100755 --- a/.github/e2e.sh +++ b/.github/e2e.sh @@ -102,14 +102,6 @@ EOF nix run nixpkgs#hurl -- --test --error-format=long .github/e2e.hurl } echo ::endgroup:: - - # Docker Compose - echo ::group::Docker healthcheck test - ! docker compose ps --format json \ - | jq -se 'map(select(.State != "running" or .Health == "unhealthy")) - | map({Service, State, Status, Health}) - | if length > 0 then . else false end' - echo ::endgroup:: ;; coverage) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21d16ae1..ff9edcec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,7 @@ jobs: timeout-minutes: 3 run: | ./.github/e2e.sh compose-setup - COMPOSE_PROFILES=demo,prometheus,loki docker compose up --detach --quiet-pull + COMPOSE_PROFILES=demo,prometheus,loki docker compose up --wait --quiet-pull - name: Run tests timeout-minutes: 3 run: | diff --git a/cmd/conntrack-fixer.go b/cmd/conntrack-fixer.go index de7f21b5..b83eb4fb 100644 --- a/cmd/conntrack-fixer.go +++ b/cmd/conntrack-fixer.go @@ -33,8 +33,8 @@ containers started with the label "akvorado.conntrack.fix=1".`, return fmt.Errorf("unable to initialize daemon component: %w", err) } httpConfiguration := httpserver.DefaultConfiguration() - httpConfiguration.Listen = "127.0.0.1:0" // Run inside host network namespace, can't use 8080 - httpComponent, err := httpserver.New(r, httpConfiguration, httpserver.Dependencies{ + httpConfiguration.Listen = "" + httpComponent, err := httpserver.New(r, "conntrack-fixer", httpConfiguration, httpserver.Dependencies{ Daemon: daemonComponent, }) if err != nil { diff --git a/cmd/console.go b/cmd/console.go index 6ba26343..f83bb0b6 100644 --- a/cmd/console.go +++ b/cmd/console.go @@ -85,7 +85,7 @@ func consoleStart(r *reporter.Reporter, config ConsoleConfiguration, checkOnly b if err != nil { return fmt.Errorf("unable to initialize daemon component: %w", err) } - httpComponent, err := httpserver.New(r, config.HTTP, httpserver.Dependencies{ + httpComponent, err := httpserver.New(r, "console", config.HTTP, httpserver.Dependencies{ Daemon: daemonComponent, }) if err != nil { diff --git a/cmd/demo-exporter.go b/cmd/demo-exporter.go index 250f8e0c..33cef50e 100644 --- a/cmd/demo-exporter.go +++ b/cmd/demo-exporter.go @@ -82,7 +82,7 @@ func demoExporterStart(r *reporter.Reporter, config DemoExporterConfiguration, c if err != nil { return fmt.Errorf("unable to initialize daemon component: %w", err) } - httpComponent, err := httpserver.New(r, config.HTTP, httpserver.Dependencies{ + httpComponent, err := httpserver.New(r, "demo-exporter", config.HTTP, httpserver.Dependencies{ Daemon: daemonComponent, }) if err != nil { diff --git a/cmd/healthcheck.go b/cmd/healthcheck.go index 04155123..fc2caeb2 100644 --- a/cmd/healthcheck.go +++ b/cmd/healthcheck.go @@ -4,15 +4,19 @@ package cmd import ( + "cmp" + "context" "fmt" + "net" "net/http" + "runtime" "github.com/spf13/cobra" ) type healthcheckOptions struct { - Host string - Port uint16 + HTTP string + UnixService string } // HealthcheckOptions stores the command-line option values for the healthcheck @@ -21,20 +25,39 @@ var HealthcheckOptions healthcheckOptions func init() { RootCmd.AddCommand(healthcheckCmd) - healthcheckCmd.Flags().Uint16VarP(&HealthcheckOptions.Port, "port", "p", 8080, - "HTTP port for health check") - healthcheckCmd.Flags().StringVarP(&HealthcheckOptions.Host, "host", "", "localhost", - "HTTP host for health check") + if runtime.GOOS == "linux" { + // On Linux, use Unix sockets + healthcheckCmd.Flags().StringVarP(&HealthcheckOptions.HTTP, "http", "", "", + "HTTP host:port for health check") + healthcheckCmd.Flags().StringVarP(&HealthcheckOptions.UnixService, "service", "", "", + "Service to query over Unix socket") + } else { + // On other OS, use HTTP + healthcheckCmd.Flags().StringVarP(&HealthcheckOptions.HTTP, "http", "", "localhost:8080", + "HTTP host:port for health check") + } } var healthcheckCmd = &cobra.Command{ Use: "healthcheck", Short: "Check healthness", - Long: `Check if Akvorado is alive using the builtin HTTP endpoint.`, + Long: `Check if Akvorado is alive using the builtin HTTP endpoint. +The service can be checked over Unix socket (by default), or over HTTP`, RunE: func(cmd *cobra.Command, _ []string) error { - resp, err := http.Get(fmt.Sprintf("http://%s:%d/api/v0/healthcheck", - HealthcheckOptions.Host, - HealthcheckOptions.Port)) + httpc := http.Client{} + if HealthcheckOptions.HTTP == "" { + unixSocket := "@akvorado" + if HealthcheckOptions.UnixService != "" { + unixSocket = fmt.Sprintf("%s/%s", unixSocket, HealthcheckOptions.UnixService) + } + httpc.Transport = &http.Transport{ + DialContext: func(context.Context, string, string) (net.Conn, error) { + return net.Dial("unix", unixSocket) + }, + } + } + resp, err := httpc.Get(fmt.Sprintf("http://%s/api/v0/healthcheck", + cmp.Or(HealthcheckOptions.HTTP, "unix"))) if err != nil { return err } diff --git a/cmd/healthcheck_test.go b/cmd/healthcheck_test.go new file mode 100644 index 00000000..a5a21464 --- /dev/null +++ b/cmd/healthcheck_test.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2025 Free Mobile +// SPDX-License-Identifier: AGPL-3.0-only + +package cmd + +import ( + "bytes" + "fmt" + "runtime" + "strings" + "testing" + + "akvorado/common/daemon" + "akvorado/common/helpers" + "akvorado/common/httpserver" + "akvorado/common/reporter" +) + +func TestHealthcheck(t *testing.T) { + // Setup a fake service + r := reporter.NewMock(t) + config := httpserver.DefaultConfiguration() + config.Listen = "127.0.0.1:0" + h, err := httpserver.New(r, "mock-healthcheck-test", config, httpserver.Dependencies{Daemon: daemon.NewMock(t)}) + if err != nil { + t.Fatalf("New() error:\n%+v", err) + } + helpers.StartStop(t, h) + h.GinRouter.GET("/api/v0/healthcheck", r.HealthcheckHTTPHandler) + + for _, tc := range []struct { + description string + args string + ok bool + }{ + // We can't really know if it works with no args, because other tests may be running in parallel. + { + description: "HTTP test", + args: fmt.Sprintf("--http %s", h.LocalAddr().String()), + ok: true, + }, { + description: "failing HTTP test", + args: "--http 127.0.0.1:0", + ok: false, + }, { + description: "unix test", + args: "--service mock-healthcheck-test", + ok: true, + }, { + description: "failing unix test", + args: "--service not-mock-healthcheck-test", + ok: false, + }, + } { + t.Run(tc.description, func(t *testing.T) { + if strings.HasPrefix(tc.args, "--service") && runtime.GOOS != "linux" { + t.Skip("unsupported OS") + } + args := []string{"healthcheck"} + args = append(args, strings.Split(tc.args, " ")...) + root := RootCmd + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetArgs(args) + HealthcheckOptions.HTTP = "" + HealthcheckOptions.UnixService = "" + t.Logf("args: %s", args) + err := root.Execute() + if err != nil && tc.ok { + t.Errorf("`healthcheck` error:\n%+v", err) + } else if err == nil && !tc.ok { + t.Error("`healthcheck` did not error") + } + }) + } +} diff --git a/cmd/inlet.go b/cmd/inlet.go index a95097ec..0fb35c58 100644 --- a/cmd/inlet.go +++ b/cmd/inlet.go @@ -77,7 +77,7 @@ func inletStart(r *reporter.Reporter, config InletConfiguration, checkOnly bool) if err != nil { return fmt.Errorf("unable to initialize daemon component: %w", err) } - httpComponent, err := httpserver.New(r, config.HTTP, httpserver.Dependencies{ + httpComponent, err := httpserver.New(r, "inlet", config.HTTP, httpserver.Dependencies{ Daemon: daemonComponent, }) if err != nil { diff --git a/cmd/orchestrator.go b/cmd/orchestrator.go index ef61a744..d85c7911 100644 --- a/cmd/orchestrator.go +++ b/cmd/orchestrator.go @@ -163,7 +163,7 @@ func init() { } func orchestratorStart(r *reporter.Reporter, config OrchestratorConfiguration, daemonComponent daemon.Component, checkOnly bool) error { - httpComponent, err := httpserver.New(r, config.HTTP, httpserver.Dependencies{ + httpComponent, err := httpserver.New(r, "orchestrator", config.HTTP, httpserver.Dependencies{ Daemon: daemonComponent, }) if err != nil { diff --git a/cmd/outlet.go b/cmd/outlet.go index 612aad83..f2a54a67 100644 --- a/cmd/outlet.go +++ b/cmd/outlet.go @@ -103,7 +103,7 @@ func outletStart(r *reporter.Reporter, config OutletConfiguration, checkOnly boo if err != nil { return fmt.Errorf("unable to initialize daemon component: %w", err) } - httpComponent, err := httpserver.New(r, config.HTTP, httpserver.Dependencies{ + httpComponent, err := httpserver.New(r, "outlet", config.HTTP, httpserver.Dependencies{ Daemon: daemonComponent, }) if err != nil { diff --git a/common/httpserver/cache_test.go b/common/httpserver/cache_test.go index 81193650..615841d7 100644 --- a/common/httpserver/cache_test.go +++ b/common/httpserver/cache_test.go @@ -139,7 +139,7 @@ func TestRedis(t *testing.T) { Server: server, DB: 10, } - h, err := httpserver.New(r, config, httpserver.Dependencies{Daemon: daemon.NewMock(t)}) + h, err := httpserver.New(r, "cache-test", config, httpserver.Dependencies{Daemon: daemon.NewMock(t)}) if err != nil { t.Fatalf("New() error:\n%+v", err) } diff --git a/common/httpserver/root.go b/common/httpserver/root.go index 52f7a5a1..b3e72446 100644 --- a/common/httpserver/root.go +++ b/common/httpserver/root.go @@ -32,9 +32,10 @@ type Component struct { t tomb.Tomb config Configuration - mux *http.ServeMux - metrics metrics - address net.Addr + mux *http.ServeMux + metrics metrics + address net.Addr + serviceName string // GinRouter is the router exposed for /api GinRouter *gin.Engine @@ -47,14 +48,15 @@ type Dependencies struct { } // New creates a new HTTP component. -func New(r *reporter.Reporter, configuration Configuration, dependencies Dependencies) (*Component, error) { +func New(r *reporter.Reporter, serviceName string, configuration Configuration, dependencies Dependencies) (*Component, error) { c := Component{ r: r, d: &dependencies, config: configuration, - mux: http.NewServeMux(), - GinRouter: gin.New(), + mux: http.NewServeMux(), + serviceName: serviceName, + GinRouter: gin.New(), } c.initMetrics() c.d.Daemon.Track(&c.t, "common/http") @@ -104,11 +106,6 @@ func (c *Component) AddHandler(location string, handler http.Handler) { // Start starts the HTTP component. func (c *Component) Start() error { - if c.config.Listen == "" { - return nil - } - - c.r.Info().Msg("starting HTTP component") var err error c.cacheStore, err = c.config.Cache.Config.New() if err != nil { @@ -116,43 +113,83 @@ func (c *Component) Start() error { } server := &http.Server{Handler: c.mux} - // Most of the time, if we have an error, it's here! - c.r.Info().Str("listen", c.config.Listen).Msg("starting HTTP server") - listener, err := net.Listen("tcp", c.config.Listen) - if err != nil { - return fmt.Errorf("unable to listen to %v: %w", c.config.Listen, err) - } - c.address = listener.Addr() - server.Addr = listener.Addr().String() - - // Start serving requests - c.t.Go(func() error { - if err := server.Serve(listener); err != http.ErrServerClosed { - c.r.Err(err).Str("listen", c.config.Listen).Msg("unable to start HTTP server") - return fmt.Errorf("unable to start HTTP server: %w", err) + for _, lc := range []struct { + network string + address string + fatal bool + }{ + // The regular one. + { + network: "tcp", + address: c.config.Listen, + fatal: true, + }, + // Using abstract unix sockets for healthchecking + { + network: "unix", + address: "@akvorado", + fatal: false, + }, + { + network: "unix", + address: fmt.Sprintf("@akvorado/%s", c.serviceName), + fatal: false, + }, + } { + if lc.address == "" { + continue + } + if lc.network == "unix" && runtime.GOOS != "linux" { + continue } - return nil - }) - // Gracefully stop when asked to + // Most of the time, if we have an error, it's here! + c.r.Info().Str("listen", lc.address).Msg("starting HTTP server") + listener, err := net.Listen(lc.network, lc.address) + if err != nil { + if lc.fatal { + return fmt.Errorf("unable to listen to %v: %w", c.config.Listen, err) + } + c.r.Info().Err(err).Msg("cannot start HTTP server") + continue + } + if lc.network == "tcp" { + c.address = listener.Addr() + } + server.Addr = listener.Addr().String() + + // Serve requests + c.t.Go(func() error { + if err := server.Serve(listener); err != http.ErrServerClosed { + c.r.Err(err).Str("listen", lc.address).Msg("unable to start HTTP server") + return fmt.Errorf("unable to start HTTP server: %w", err) + } + return nil + }) + // Gracefully stop when asked to + c.t.Go(func() error { + <-c.t.Dying() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + c.r.Err(err).Msg("unable to shutdown HTTP server") + return fmt.Errorf("unable to shutdown HTTP server: %w", err) + } + return nil + }) + } + + // In case we have no server c.t.Go(func() error { <-c.t.Dying() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := server.Shutdown(ctx); err != nil { - c.r.Err(err).Msg("unable to shutdown HTTP server") - return fmt.Errorf("unable to shutdown HTTP server: %w", err) - } return nil }) + return nil } // Stop stops the HTTP component func (c *Component) Stop() error { - if c.config.Listen == "" { - return nil - } c.r.Info().Msg("stopping HTTP component") defer c.r.Info().Msg("HTTP component stopped") c.t.Kill(nil) diff --git a/common/httpserver/root_test.go b/common/httpserver/root_test.go index 2e316984..873e64fa 100644 --- a/common/httpserver/root_test.go +++ b/common/httpserver/root_test.go @@ -4,10 +4,15 @@ package httpserver_test import ( + "context" "fmt" + "io" + "net" "net/http" + "runtime" "testing" + "akvorado/common/daemon" "akvorado/common/helpers" "akvorado/common/httpserver" "akvorado/common/reporter" @@ -91,3 +96,49 @@ func TestGinRouterPanic(t *testing.T) { }, }) } + +func TestUnixSocket(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("unsupported OS") + } + r := reporter.NewMock(t) + config := httpserver.DefaultConfiguration() + config.Listen = "" + h, err := httpserver.New(r, "mock-unix-test", config, httpserver.Dependencies{Daemon: daemon.NewMock(t)}) + if err != nil { + t.Fatalf("New() error:\n%+v", err) + } + helpers.StartStop(t, h) + + h.AddHandler("/test", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "Hello !") + })) + + // We should listen to both @akvorado and @akvorado/mock-unix-test. However, + // we may have some parallel tests and @akvorado may not be the handler we + // configured. Let's just test the second one. + httpc := http.Client{ + Transport: &http.Transport{ + DialContext: func(context.Context, string, string) (net.Conn, error) { + return net.Dial("unix", "@akvorado/mock-unix-test") + }, + }, + } + response, err := httpc.Get("http://unix/test") + if err != nil { + t.Fatalf("Get() error:\n%+v", err) + } + defer response.Body.Close() + if response.StatusCode != 200 { + t.Errorf("Get() status = %d instead of %d", response.StatusCode, 200) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("ReadAll() error:\n%+v", err) + } + expected := "Hello !" + if diff := helpers.Diff(string(body), expected); diff != "" { + t.Fatalf("Get() body (-got, +want):\n%s", diff) + } +} diff --git a/common/httpserver/tests.go b/common/httpserver/tests.go index 4a91181b..f08b4007 100644 --- a/common/httpserver/tests.go +++ b/common/httpserver/tests.go @@ -18,7 +18,7 @@ func NewMock(t testing.TB, r *reporter.Reporter) *Component { t.Helper() config := DefaultConfiguration() config.Listen = "0.0.0.0:0" - c, err := New(r, config, Dependencies{Daemon: daemon.NewMock(t)}) + c, err := New(r, "mock", config, Dependencies{Daemon: daemon.NewMock(t)}) if err != nil { t.Fatalf("New() error:\n%+v", err) } diff --git a/console/data/docs/99-changelog.md b/console/data/docs/99-changelog.md index 89e3e131..c05fdd31 100644 --- a/console/data/docs/99-changelog.md +++ b/console/data/docs/99-changelog.md @@ -14,7 +14,7 @@ identified with a specific icon: - 🩹 *docker*: restart geoip container on boot - 🌱 *orchestrator*: add `kafka`→`manage-topic` flag to enable or disable topic management -- 🌱 *cmd*: add `--host` and `--port` flags to `akvorado healthcheck` +- 🌱 *cmd*: make `akvorado healthcheck` use an abstract Unix socket to check service liveness ## 2.0.3 - 2025-11-19 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 58a42202..e6111015 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -213,7 +213,7 @@ services: restart: unless-stopped network_mode: host healthcheck: - disable: true + test: ["CMD", "/usr/local/bin/akvorado", "healthcheck", "--service", "conntrack-fixer"] volumes: - /var/run/docker.sock:/var/run/docker.sock:ro