diff --git a/checker/server/healthcheck.go b/checker/server/healthcheck.go new file mode 100644 index 0000000..bb79d22 --- /dev/null +++ b/checker/server/healthcheck.go @@ -0,0 +1,81 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +// healthcheckMode is registered on the default flag set so any consumer that +// calls flag.Parse() before ListenAndServe (the standard pattern in our +// checker mains) gets the behaviour for free. When set, ListenAndServe +// performs a short-lived HTTP probe against /health on the configured listen +// address and exits 0/1 instead of starting the server. This lets the same +// binary act as its own Docker HEALTHCHECK probe for scratch images, where +// no shell, curl or wget is available. +var healthcheckMode = flag.Bool( + "healthcheck", + false, + "probe /health on the server's listen address and exit 0 if healthy, 1 "+ + "otherwise (intended as a Docker HEALTHCHECK for scratch-based images)", +) + +// runHealthcheck performs a GET against http:///health with a short +// timeout. Returns nil on a 2xx response, an error otherwise. A bind address +// like ":8080" or "0.0.0.0:8080" is rewritten to dial the loopback interface +// so the probe targets the local process. +func runHealthcheck(addr string) error { + host, port, err := net.SplitHostPort(normalizeHealthcheckAddr(addr)) + if err != nil { + return fmt.Errorf("invalid listen addr %q: %w", addr, err) + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, port)) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode) + } + return nil +} + +func normalizeHealthcheckAddr(a string) string { + if strings.HasPrefix(a, ":") { + return "127.0.0.1" + a + } + if strings.HasPrefix(a, "[::]:") { + return "[::1]:" + strings.TrimPrefix(a, "[::]:") + } + return a +} diff --git a/checker/server/healthcheck_test.go b/checker/server/healthcheck_test.go new file mode 100644 index 0000000..daa4bc5 --- /dev/null +++ b/checker/server/healthcheck_test.go @@ -0,0 +1,72 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRunHealthcheck_OK(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + addr := strings.TrimPrefix(srv.URL, "http://") + if err := runHealthcheck(addr); err != nil { + t.Fatalf("runHealthcheck(%s) returned error: %v", addr, err) + } +} + +func TestRunHealthcheck_NonOK(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + addr := strings.TrimPrefix(srv.URL, "http://") + if err := runHealthcheck(addr); err == nil { + t.Fatalf("runHealthcheck against 503 returned nil; want error") + } +} + +func TestRunHealthcheck_Unreachable(t *testing.T) { + // Reserved-for-documentation port on loopback that nothing should bind. + if err := runHealthcheck("127.0.0.1:1"); err == nil { + t.Fatalf("runHealthcheck against unreachable port returned nil; want error") + } +} + +func TestNormalizeHealthcheckAddr(t *testing.T) { + cases := map[string]string{ + ":8080": "127.0.0.1:8080", + "127.0.0.1:8080": "127.0.0.1:8080", + "0.0.0.0:8080": "0.0.0.0:8080", + "[::1]:8080": "[::1]:8080", + "[::]:8080": "[::1]:8080", + } + for in, want := range cases { + if got := normalizeHealthcheckAddr(in); got != want { + t.Errorf("normalizeHealthcheckAddr(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/checker/server/server.go b/checker/server/server.go index 26f50ee..caae01f 100644 --- a/checker/server/server.go +++ b/checker/server/server.go @@ -26,6 +26,7 @@ import ( "log" "math" "net/http" + "os" "runtime" "strings" "sync" @@ -170,7 +171,19 @@ func (s *Server) HandleFunc(pattern string, handler func(http.ResponseWriter, *h // ListenAndServe does not stop the background load-average sampler on return; // call Close to stop it. This is not required for process-scoped usage but is // recommended for tests and embedded lifecycles. +// +// If the consumer's flag.Parse() set the SDK-registered -healthcheck flag, +// ListenAndServe never starts the server: it probes /health on addr and calls +// os.Exit(0) on success or os.Exit(1) on failure. This is what lets a +// scratch-based Docker image use the binary itself as its HEALTHCHECK probe. func (s *Server) ListenAndServe(addr string) error { + if *healthcheckMode { + if err := runHealthcheck(addr); err != nil { + fmt.Fprintln(os.Stderr, "healthcheck failed:", err) + os.Exit(1) + } + os.Exit(0) + } log.Printf("checker listening on %s", addr) return http.ListenAndServe(addr, requestLogger(s.mux)) }