feat: design a health subcommand
This commit is contained in:
@@ -59,6 +59,7 @@ func printRootUsage() {
|
|||||||
fmt.Fprintln(os.Stderr, " post manage posts (delete, list, search)")
|
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, " prune hard delete users and posts (posts, all)")
|
||||||
fmt.Fprintln(os.Stderr, " seed seed database with random data")
|
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() {
|
func printRunUsage() {
|
||||||
@@ -180,6 +181,14 @@ func buildRootCommand(cfg *config.Config) *cli.Command {
|
|||||||
return commands.HandleSeedCommand(cfg, cmd.Name, cmd.Args().Slice())
|
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,
|
Writer: os.Stdout,
|
||||||
ErrWriter: os.Stderr,
|
ErrWriter: os.Stderr,
|
||||||
|
|||||||
129
cmd/goyco/commands/health.go
Normal file
129
cmd/goyco/commands/health.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
205
cmd/goyco/commands/health_test.go
Normal file
205
cmd/goyco/commands/health_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user