Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ WORKDIR /app

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/gomodel", "--health"]

ENTRYPOINT ["/gomodel"]
40 changes: 40 additions & 0 deletions cmd/gomodel/flags.go
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func cliParseExitCode(err error) int {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
53 changes: 53 additions & 0 deletions cmd/gomodel/flags_test.go
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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 2 in the main path (cliParseExitCode). Add an assertion on the returned error to cover that contract and the remaining non-help branch in cmd/gomodel/flags.go.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/gomodel/flags_test.go` around lines 40 - 44, The test
TestParseCLI_RejectsPositionalArgs only verifies that an error is returned for
positional arguments, but it doesn't verify that the error maps to the expected
exit code 2 (cliParseExitCode). Add an assertion to check that the returned
error from the parseCLI call equals cliParseExitCode, ensuring the test covers
the full contract for parse failures and validates the non-help branch exit code
mapping.


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)
}
}
72 changes: 72 additions & 0 deletions cmd/gomodel/health.go
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
}
130 changes: 130 additions & 0 deletions cmd/gomodel/health_test.go
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)
}
}
17 changes: 13 additions & 4 deletions cmd/gomodel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
Expand Down Expand Up @@ -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)
Expand Down
Loading