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/cmd/gomodel/flags.go b/cmd/gomodel/flags.go new file mode 100644 index 00000000..f24738e8 --- /dev/null +++ b/cmd/gomodel/flags.go @@ -0,0 +1,40 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "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") + 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 { + 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..31f7f1bc --- /dev/null +++ b/cmd/gomodel/flags_test.go @@ -0,0 +1,53 @@ +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 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) + } + 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/advanced/cli.mdx b/docs/advanced/cli.mdx new file mode 100644 index 00000000..74d4186d --- /dev/null +++ b/docs/advanced/cli.mdx @@ -0,0 +1,61 @@ +--- +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 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 +``` + +### 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/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 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",