From a854138eac3709b03c4aefce626ce06324661dd1 Mon Sep 17 00:00:00 2001 From: Kharec Date: Sun, 15 Feb 2026 11:59:16 +0100 Subject: [PATCH] feat: design a health subcommand --- cmd/goyco/cli.go | 9 ++ cmd/goyco/commands/health.go | 129 +++++++++++++++++++ cmd/goyco/commands/health_test.go | 205 ++++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 cmd/goyco/commands/health.go create mode 100644 cmd/goyco/commands/health_test.go diff --git a/cmd/goyco/cli.go b/cmd/goyco/cli.go index 701387a..46aa0c6 100644 --- a/cmd/goyco/cli.go +++ b/cmd/goyco/cli.go @@ -59,6 +59,7 @@ func printRootUsage() { fmt.Fprintln(os.Stderr, " post manage posts (delete, list, search)") fmt.Fprintln(os.Stderr, " prune hard delete users and posts (posts, all)") fmt.Fprintln(os.Stderr, " seed seed database with random data") + fmt.Fprintln(os.Stderr, " health check the health of the application and its dependencies") } func printRunUsage() { @@ -180,6 +181,14 @@ func buildRootCommand(cfg *config.Config) *cli.Command { return commands.HandleSeedCommand(cfg, cmd.Name, cmd.Args().Slice()) }, }, + { + Name: "health", + Usage: "check the health of the application and its dependencies", + SkipFlagParsing: true, + Action: func(_ context.Context, cmd *cli.Command) error { + return commands.HandleHealthCommand(cfg, cmd.Name, cmd.Args().Slice()) + }, + }, }, Writer: os.Stdout, ErrWriter: os.Stderr, diff --git a/cmd/goyco/commands/health.go b/cmd/goyco/commands/health.go new file mode 100644 index 0000000..31901bc --- /dev/null +++ b/cmd/goyco/commands/health.go @@ -0,0 +1,129 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "goyco/internal/config" + "goyco/internal/database" + "goyco/internal/health" + "goyco/internal/middleware" + "goyco/internal/version" +) + +func HandleHealthCommand(cfg *config.Config, name string, args []string) error { + fs := newFlagSet(name, printHealthUsage) + if err := parseCommand(fs, args, name); err != nil { + if errors.Is(err, ErrHelpRequested) { + return nil + } + return err + } + + if fs.NArg() > 0 { + printHealthUsage() + return errors.New("unexpected arguments for health command") + } + + return runHealthCheck(cfg) +} + +func printHealthUsage() { + fmt.Fprintln(os.Stderr, "Usage: goyco health") + fmt.Fprintln(os.Stderr, "\nCheck the health status of the application and its dependencies.") +} + +func runHealthCheck(cfg *config.Config) error { + compositeChecker := health.NewCompositeChecker() + + dbChecker, err := createDatabaseChecker(cfg) + if err != nil { + return fmt.Errorf("failed to create database checker: %w", err) + } + if dbChecker != nil { + compositeChecker.AddChecker(dbChecker) + } + + smtpChecker := createSMTPChecker(cfg) + if smtpChecker != nil { + compositeChecker.AddChecker(smtpChecker) + } + + ctx := context.Background() + result := compositeChecker.CheckWithVersion(ctx, version.GetVersion()) + + if IsJSONOutput() { + return outputJSON(result) + } + + return printHealthResult(result) +} + +func createDatabaseChecker(cfg *config.Config) (health.Checker, error) { + db, err := database.Connect(cfg) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get sql.DB: %w", err) + } + + sqlDB.SetConnMaxLifetime(5 * time.Second) + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + monitor := middleware.NewInMemoryDBMonitor() + return health.NewDatabaseChecker(sqlDB, monitor), nil +} + +func createSMTPChecker(cfg *config.Config) health.Checker { + if cfg.SMTP.Host == "" { + return nil + } + + smtpConfig := health.SMTPConfig{ + Host: cfg.SMTP.Host, + Port: cfg.SMTP.Port, + Username: cfg.SMTP.Username, + Password: cfg.SMTP.Password, + From: cfg.SMTP.From, + } + + return health.NewSMTPChecker(smtpConfig) +} + +func printHealthResult(result health.OverallResult) error { + fmt.Printf("Health Status: %s\n", result.Status) + fmt.Printf("Version: %s\n", result.Version) + fmt.Printf("Timestamp: %s\n", result.Timestamp.Format(time.RFC3339)) + fmt.Println() + + if len(result.Services) == 0 { + fmt.Println("No services configured.") + return nil + } + + fmt.Println("Services:") + for name, service := range result.Services { + fmt.Printf(" %s:\n", name) + fmt.Printf(" Status: %s\n", service.Status) + fmt.Printf(" Latency: %s\n", service.Latency) + if service.Message != "" { + fmt.Printf(" Message: %s\n", service.Message) + } + if len(service.Details) > 0 { + fmt.Printf(" Details:\n") + for key, value := range service.Details { + fmt.Printf(" %s: %v\n", key, value) + } + } + fmt.Println() + } + + return nil +} diff --git a/cmd/goyco/commands/health_test.go b/cmd/goyco/commands/health_test.go new file mode 100644 index 0000000..0c21e2c --- /dev/null +++ b/cmd/goyco/commands/health_test.go @@ -0,0 +1,205 @@ +package commands + +import ( + "os" + "strings" + "testing" + "time" + + "goyco/internal/config" + "goyco/internal/health" + "goyco/internal/testutils" +) + +func TestHandleHealthCommand(t *testing.T) { + cfg := testutils.NewTestConfig() + + t.Run("help requested", func(t *testing.T) { + err := HandleHealthCommand(cfg, "health", []string{"--help"}) + + if err != nil { + t.Errorf("unexpected error for help: %v", err) + } + }) + + t.Run("unexpected arguments", func(t *testing.T) { + err := HandleHealthCommand(cfg, "health", []string{"extra", "args"}) + + if err == nil { + t.Error("expected error for unexpected arguments") + } + + if !strings.Contains(err.Error(), "unexpected arguments") { + t.Errorf("expected error containing 'unexpected arguments', got %q", err.Error()) + } + }) +} + +func TestPrintHealthUsage(t *testing.T) { + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printHealthUsage() + + w.Close() + os.Stderr = oldStderr + + buf := make([]byte, 1024) + n, _ := r.Read(buf) + output := string(buf[:n]) + + if !strings.Contains(output, "Usage: goyco health") { + t.Errorf("expected usage to contain 'Usage: goyco health', got %q", output) + } +} + +func TestCreateSMTPChecker(t *testing.T) { + t.Run("with valid smtp config", func(t *testing.T) { + cfg := &config.Config{ + SMTP: config.SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + }, + } + + checker := createSMTPChecker(cfg) + + if checker == nil { + t.Error("expected checker to be created") + } + + if checker.Name() != "smtp" { + t.Errorf("expected name 'smtp', got %s", checker.Name()) + } + }) + + t.Run("with empty host", func(t *testing.T) { + cfg := &config.Config{ + SMTP: config.SMTPConfig{ + Host: "", + Port: 587, + }, + } + + checker := createSMTPChecker(cfg) + + if checker != nil { + t.Error("expected checker to be nil when host is empty") + } + }) +} + +func TestPrintHealthResult(t *testing.T) { + t.Run("healthy result", func(t *testing.T) { + result := health.OverallResult{ + Status: health.StatusHealthy, + Version: "v1.0.0", + Timestamp: time.Now().UTC(), + Services: map[string]health.Result{ + "database": { + Status: health.StatusHealthy, + Latency: 2 * time.Millisecond, + Details: map[string]any{ + "ping_time": "2ms", + }, + }, + }, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := printHealthResult(result) + + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + buf := make([]byte, 1024) + n, _ := r.Read(buf) + output := string(buf[:n]) + + if !strings.Contains(output, "Health Status: healthy") { + t.Errorf("expected 'Health Status: healthy', got %q", output) + } + + if !strings.Contains(output, "Version: v1.0.0") { + t.Errorf("expected 'Version: v1.0.0', got %q", output) + } + }) + + t.Run("degraded result with message", func(t *testing.T) { + result := health.OverallResult{ + Status: health.StatusDegraded, + Version: "v1.0.0", + Timestamp: time.Now().UTC(), + Services: map[string]health.Result{ + "smtp": { + Status: health.StatusDegraded, + Message: "Connection failed", + Latency: 5 * time.Millisecond, + }, + }, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := printHealthResult(result) + + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + buf := make([]byte, 1024) + n, _ := r.Read(buf) + output := string(buf[:n]) + + if !strings.Contains(output, "Health Status: degraded") { + t.Errorf("expected 'Health Status: degraded', got %q", output) + } + + if !strings.Contains(output, "Connection failed") { + t.Errorf("expected error message in output, got %q", output) + } + }) + + t.Run("empty services", func(t *testing.T) { + result := health.OverallResult{ + Status: health.StatusHealthy, + Version: "v1.0.0", + Timestamp: time.Now().UTC(), + Services: map[string]health.Result{}, + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := printHealthResult(result) + + w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + buf := make([]byte, 1024) + n, _ := r.Read(buf) + output := string(buf[:n]) + + if !strings.Contains(output, "No services configured") { + t.Errorf("expected 'No services configured', got %q", output) + } + }) +}