From 08565603d02fe88d133ca9e338819c53daddfde0 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 16 Jun 2026 11:54:01 +0200 Subject: [PATCH 1/3] feat(cli): add --health probe and Docker HEALTHCHECK Add a `--health` CLI flag that probes the local /health endpoint and exits non-zero on failure, plus a `--health-timeout` to bound the request. Wire it into the Dockerfile as a HEALTHCHECK so container orchestrators can detect an unhealthy gateway without bundling curl/wget into the image. CLI parsing moves to a testable FlagSet (parseCLI) that accepts both single- and double-dash flags and reports parse errors via exit code instead of os.Exit. The probe reuses config.Load so it honors the configured PORT and BASE_PATH. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 2 + README.md | 10 +++ cmd/gomodel/flags.go | 33 ++++++++++ cmd/gomodel/flags_test.go | 47 ++++++++++++++ cmd/gomodel/health.go | 72 ++++++++++++++++++++ cmd/gomodel/health_test.go | 130 +++++++++++++++++++++++++++++++++++++ cmd/gomodel/main.go | 17 +++-- docs/dev/api-examples.md | 2 + 8 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 cmd/gomodel/flags.go create mode 100644 cmd/gomodel/flags_test.go create mode 100644 cmd/gomodel/health.go create mode 100644 cmd/gomodel/health_test.go diff --git a/Dockerfile b/Dockerfile index 801d5931..ae621fb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,4 +43,6 @@ WORKDIR /app EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/gomodel", "--health"] + ENTRYPOINT ["/gomodel"] diff --git a/README.md b/README.md index e9c3a67f..850f99dd 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,16 @@ docker run --rm -p 8080:8080 --env-file .env gomodel | `/metrics` | GET | Prometheus metrics (experimental, when enabled) | | `/swagger/index.html` | GET | Swagger UI (when enabled) | +### CLI Operations + +GoModel accepts both single-dash and double-dash CLI flags. Examples use the +long form: + +```bash +gomodel --version +gomodel --health +``` + --- ## Gateway Configuration diff --git a/cmd/gomodel/flags.go b/cmd/gomodel/flags.go new file mode 100644 index 00000000..a28e7608 --- /dev/null +++ b/cmd/gomodel/flags.go @@ -0,0 +1,33 @@ +package main + +import ( + "errors" + "flag" + "io" + "time" +) + +const defaultHealthTimeout = 2 * time.Second + +type cliOptions struct { + Version bool + Health bool + HealthTimeout time.Duration +} + +func parseCLI(args []string, output io.Writer) (cliOptions, error) { + var opts cliOptions + flags := flag.NewFlagSet("gomodel", flag.ContinueOnError) + flags.SetOutput(output) + flags.BoolVar(&opts.Version, "version", false, "Print version information") + flags.BoolVar(&opts.Health, "health", false, "Check the local GoModel health endpoint and exit") + flags.DurationVar(&opts.HealthTimeout, "health-timeout", defaultHealthTimeout, "Timeout for --health") + return opts, flags.Parse(args) +} + +func cliParseExitCode(err error) int { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 +} diff --git a/cmd/gomodel/flags_test.go b/cmd/gomodel/flags_test.go new file mode 100644 index 00000000..9494af42 --- /dev/null +++ b/cmd/gomodel/flags_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "io" + "testing" +) + +func TestParseCLI_AcceptsSingleAndDoubleDashVersion(t *testing.T) { + for _, args := range [][]string{{"-version"}, {"--version"}} { + opts, err := parseCLI(args, io.Discard) + if err != nil { + t.Fatalf("parseCLI(%v) error = %v", args, err) + } + if !opts.Version { + t.Fatalf("parseCLI(%v).Version = false, want true", args) + } + } +} + +func TestParseCLI_AcceptsSingleAndDoubleDashHealth(t *testing.T) { + for _, args := range [][]string{{"-health"}, {"--health"}} { + opts, err := parseCLI(args, io.Discard) + if err != nil { + t.Fatalf("parseCLI(%v) error = %v", args, err) + } + if !opts.Health { + t.Fatalf("parseCLI(%v).Health = false, want true", args) + } + } +} + +func TestParseCLI_RejectsUnknownFlags(t *testing.T) { + if _, err := parseCLI([]string{"--helath"}, io.Discard); err == nil { + t.Fatal("parseCLI(--helath) error = nil, want error") + } +} + +func TestCLIParseExitCode(t *testing.T) { + if got := cliParseExitCode(flag.ErrHelp); got != 0 { + t.Fatalf("cliParseExitCode(flag.ErrHelp) = %d, want 0", got) + } + if got := cliParseExitCode(fmt.Errorf("parse flags: %w", flag.ErrHelp)); got != 0 { + t.Fatalf("cliParseExitCode(wrapped help) = %d, want 0", got) + } +} diff --git a/cmd/gomodel/health.go b/cmd/gomodel/health.go new file mode 100644 index 00000000..989bef9c --- /dev/null +++ b/cmd/gomodel/health.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "gomodel/config" +) + +func runHealthProbe(timeout time.Duration) error { + result, err := config.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + endpoint := healthProbeURL(result.Config.Server) + if timeout <= 0 { + timeout = defaultHealthTimeout + } + + client := &http.Client{Timeout: timeout} + return checkHealthEndpoint(context.Background(), client, endpoint) +} + +func healthProbeURL(server config.ServerConfig) string { + port := strings.TrimSpace(server.Port) + if port == "" { + port = "8080" + } + + u := url.URL{ + Scheme: "http", + Host: net.JoinHostPort("127.0.0.1", port), + Path: config.JoinBasePath(server.BasePath, "/health"), + } + return u.String() +} + +func checkHealthEndpoint(ctx context.Context, client *http.Client, endpoint string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("build health request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request health endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health endpoint returned HTTP %d", resp.StatusCode) + } + + var body struct { + Status string `json:"status"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&body); err != nil { + return fmt.Errorf("decode health response: %w", err) + } + if body.Status != "ok" { + return fmt.Errorf("health status is %q", body.Status) + } + return nil +} diff --git a/cmd/gomodel/health_test.go b/cmd/gomodel/health_test.go new file mode 100644 index 00000000..150b87d1 --- /dev/null +++ b/cmd/gomodel/health_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gomodel/config" +) + +func TestHealthProbeURL(t *testing.T) { + tests := []struct { + name string + server config.ServerConfig + expected string + }{ + { + name: "default base path", + server: config.ServerConfig{Port: "8080", BasePath: "/"}, + expected: "http://127.0.0.1:8080/health", + }, + { + name: "prefixed base path", + server: config.ServerConfig{Port: "9090", BasePath: "/g"}, + expected: "http://127.0.0.1:9090/g/health", + }, + { + name: "empty port falls back to default", + server: config.ServerConfig{BasePath: "/"}, + expected: "http://127.0.0.1:8080/health", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := healthProbeURL(tt.server) + if got != tt.expected { + t.Fatalf("healthProbeURL() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestCheckHealthEndpoint(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantErr string + }{ + { + name: "healthy", + statusCode: http.StatusOK, + body: `{"status":"ok"}`, + }, + { + name: "bad status code", + statusCode: http.StatusServiceUnavailable, + body: `{"status":"ok"}`, + wantErr: "HTTP 503", + }, + { + name: "bad response body", + statusCode: http.StatusOK, + body: `not json`, + wantErr: "decode health response", + }, + { + name: "unhealthy response status", + statusCode: http.StatusOK, + body: `{"status":"starting"}`, + wantErr: `health status is "starting"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = fmt.Fprint(w, tt.body) + })) + defer server.Close() + + err := checkHealthEndpoint(context.Background(), server.Client(), server.URL) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("checkHealthEndpoint() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("checkHealthEndpoint() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestRunHealthProbe_UsesConfiguredPortAndBasePath(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/g/health" { + http.NotFound(w, r) + return + } + _, _ = fmt.Fprint(w, `{"status":"ok"}`) + })) + server.Listener = listener + server.Start() + defer server.Close() + + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatalf("split listener address: %v", err) + } + t.Setenv("PORT", port) + t.Setenv("BASE_PATH", "/g") + + if err := runHealthProbe(time.Second); err != nil { + t.Fatalf("runHealthProbe() error = %v, want nil", err) + } +} diff --git a/cmd/gomodel/main.go b/cmd/gomodel/main.go index 67b44a9f..74b9c486 100644 --- a/cmd/gomodel/main.go +++ b/cmd/gomodel/main.go @@ -4,7 +4,6 @@ package main import ( "context" "errors" - "flag" "fmt" "log/slog" "os" @@ -85,16 +84,26 @@ func startApplication(application lifecycleApp, addr string) error { // @in header // @name Authorization func main() { - versionFlag := flag.Bool("version", false, "Print version information") - flag.Parse() + opts, err := parseCLI(os.Args[1:], os.Stderr) + if err != nil { + os.Exit(cliParseExitCode(err)) + } - if *versionFlag { + if opts.Version { fmt.Println(version.Info()) os.Exit(0) } _ = godotenv.Load() + if opts.Health { + if err := runHealthProbe(opts.HealthTimeout); err != nil { + fmt.Fprintf(os.Stderr, "health check failed: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + if err := configureLogging(os.Stderr); err != nil { fmt.Fprintf(os.Stderr, "failed to configure logging: %v\n", err) os.Exit(1) diff --git a/docs/dev/api-examples.md b/docs/dev/api-examples.md index a7aefa0a..29fd89dc 100644 --- a/docs/dev/api-examples.md +++ b/docs/dev/api-examples.md @@ -311,6 +311,8 @@ Example response: ```bash curl http://localhost:8080/health +gomodel --health +gomodel --version ``` ## Client Library Examples From 086a6cc030fc04c9fc250218aa24687acd59a50e Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 16 Jun 2026 12:09:10 +0200 Subject: [PATCH 2/3] docs(cli): move CLI operations to advanced docs Add a dedicated Advanced > CLI Operations page documenting --version, --health, --health-timeout, and the Docker HEALTHCHECK usage, and register it in the docs navigation. Drop the brief CLI Operations section from the README so the docs site is the single source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 -------- docs/advanced/cli.mdx | 60 +++++++++++++++++++++++++++++++++++++++++++ docs/docs.json | 1 + 3 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 docs/advanced/cli.mdx diff --git a/README.md b/README.md index 850f99dd..e9c3a67f 100644 --- a/README.md +++ b/README.md @@ -276,16 +276,6 @@ docker run --rm -p 8080:8080 --env-file .env gomodel | `/metrics` | GET | Prometheus metrics (experimental, when enabled) | | `/swagger/index.html` | GET | Swagger UI (when enabled) | -### CLI Operations - -GoModel accepts both single-dash and double-dash CLI flags. Examples use the -long form: - -```bash -gomodel --version -gomodel --health -``` - --- ## Gateway Configuration diff --git a/docs/advanced/cli.mdx b/docs/advanced/cli.mdx new file mode 100644 index 00000000..d1fc3a65 --- /dev/null +++ b/docs/advanced/cli.mdx @@ -0,0 +1,60 @@ +--- +title: "CLI Operations" +description: "Command-line flags for inspecting the GoModel binary and probing a running gateway's health." +icon: "terminal" +--- + +## Overview + +The `gomodel` binary exposes a small set of command-line flags for operational +tasks. Flags accept both single-dash (`-flag`) and double-dash (`--flag`) forms; +the examples below use the long form. + +| Flag | Description | Default | +| ------------------ | ------------------------------------------------------- | ------- | +| `--version` | Print version information and exit | — | +| `--health` | Probe the local `/health` endpoint and exit | — | +| `--health-timeout` | Maximum time to wait for the `--health` probe | `2s` | + +## Version + +Print the build version and exit: + +```bash +gomodel --version +``` + +## Health probe + +`--health` makes the binary act as a health-check client: it loads the same +configuration as the server, requests the local `/health` endpoint, and exits. + +```bash +gomodel --health +``` + +- Exits `0` when the endpoint returns HTTP `200` with `{"status":"ok"}`. +- Exits non-zero otherwise (connection refused, non-`200` status, or any other + status value). + +The probe honors the configured `PORT` and `BASE_PATH`, so it targets the same +address the server listens on without hardcoding `localhost:8080`. Bound the +request with `--health-timeout`: + +```bash +gomodel --health --health-timeout 5s +``` + +### Docker `HEALTHCHECK` + +Because the probe is built into the binary, container images can report health +without shipping `curl` or `wget` — useful for minimal/distroless runtimes. The +official image wires it up automatically: + +```dockerfile +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/gomodel", "--health"] +``` + +Orchestrators (Docker, Compose, Kubernetes) can then detect and restart an +unhealthy gateway. diff --git a/docs/docs.json b/docs/docs.json index 680e8e7e..275652c0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -59,6 +59,7 @@ "pages": [ "advanced/configuration", "advanced/config-yaml", + "advanced/cli", "advanced/resilience", "advanced/responses-api", "advanced/responses-compatibility", From 767c3f443e3a16ce0cd72cf96c8c38333b32de21 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 16 Jun 2026 12:19:23 +0200 Subject: [PATCH 3/3] fix(cli): reject unexpected positional args and clarify probe host Address review feedback: - parseCLI now errors on leftover positional arguments so typoed invocations like `gomodel --health extra` fail loudly instead of silently succeeding. - Clarify in the docs that the health probe always targets loopback (127.0.0.1) and only derives PORT/BASE_PATH from config. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/gomodel/flags.go | 9 ++++++++- cmd/gomodel/flags_test.go | 6 ++++++ docs/advanced/cli.mdx | 7 ++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/gomodel/flags.go b/cmd/gomodel/flags.go index a28e7608..f24738e8 100644 --- a/cmd/gomodel/flags.go +++ b/cmd/gomodel/flags.go @@ -3,6 +3,7 @@ package main import ( "errors" "flag" + "fmt" "io" "time" ) @@ -22,7 +23,13 @@ func parseCLI(args []string, output io.Writer) (cliOptions, error) { flags.BoolVar(&opts.Version, "version", false, "Print version information") flags.BoolVar(&opts.Health, "health", false, "Check the local GoModel health endpoint and exit") flags.DurationVar(&opts.HealthTimeout, "health-timeout", defaultHealthTimeout, "Timeout for --health") - return opts, flags.Parse(args) + if err := flags.Parse(args); err != nil { + return opts, err + } + if flags.NArg() > 0 { + return opts, fmt.Errorf("unexpected arguments: %v", flags.Args()) + } + return opts, nil } func cliParseExitCode(err error) int { diff --git a/cmd/gomodel/flags_test.go b/cmd/gomodel/flags_test.go index 9494af42..31f7f1bc 100644 --- a/cmd/gomodel/flags_test.go +++ b/cmd/gomodel/flags_test.go @@ -37,6 +37,12 @@ func TestParseCLI_RejectsUnknownFlags(t *testing.T) { } } +func TestParseCLI_RejectsPositionalArgs(t *testing.T) { + if _, err := parseCLI([]string{"--health", "extra"}, io.Discard); err == nil { + t.Fatal("parseCLI(--health extra) error = nil, want error") + } +} + func TestCLIParseExitCode(t *testing.T) { if got := cliParseExitCode(flag.ErrHelp); got != 0 { t.Fatalf("cliParseExitCode(flag.ErrHelp) = %d, want 0", got) diff --git a/docs/advanced/cli.mdx b/docs/advanced/cli.mdx index d1fc3a65..74d4186d 100644 --- a/docs/advanced/cli.mdx +++ b/docs/advanced/cli.mdx @@ -37,9 +37,10 @@ gomodel --health - Exits non-zero otherwise (connection refused, non-`200` status, or any other status value). -The probe honors the configured `PORT` and `BASE_PATH`, so it targets the same -address the server listens on without hardcoding `localhost:8080`. Bound the -request with `--health-timeout`: +The probe always targets the loopback interface (`127.0.0.1`) since it runs +inside the same container as the server, but it derives the `PORT` and +`BASE_PATH` from configuration instead of hardcoding `8080` and `/health`. Bound +the request with `--health-timeout`: ```bash gomodel --health --health-timeout 5s