-
-
Notifications
You must be signed in to change notification settings - Fork 70
feat(cli): add --health probe and Docker HEALTHCHECK #404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } | ||
| } | ||
|
Comment on lines
+40
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert exit-code mapping for positional-argument parse failures. Line 41 verifies parse failure, but it doesn’t verify that this failure maps to exit code Suggested patch func TestParseCLI_RejectsPositionalArgs(t *testing.T) {
- if _, err := parseCLI([]string{"--health", "extra"}, io.Discard); err == nil {
+ _, err := parseCLI([]string{"--health", "extra"}, io.Discard)
+ if err == nil {
t.Fatal("parseCLI(--health extra) error = nil, want error")
}
+ if got := cliParseExitCode(err); got != 2 {
+ t.Fatalf("cliParseExitCode(positional args) = %d, want 2", got)
+ }
}🤖 Prompt for AI Agents |
||
|
|
||
| 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) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.