feat: design a separate package for health check

This commit is contained in:
2026-02-15 11:56:12 +01:00
parent 8f30fe7412
commit a3ed6685de
5 changed files with 572 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
package health
import (
"context"
"sync"
"time"
)
type CompositeChecker struct {
checkers []Checker
}
func NewCompositeChecker(checkers ...Checker) *CompositeChecker {
return &CompositeChecker{
checkers: checkers,
}
}
func (c *CompositeChecker) AddChecker(checker Checker) {
c.checkers = append(c.checkers, checker)
}
func (c *CompositeChecker) Check(ctx context.Context) OverallResult {
results := make(map[string]Result)
var mu sync.Mutex
var wg sync.WaitGroup
for _, checker := range c.checkers {
wg.Add(1)
go func(ch Checker) {
defer wg.Done()
result := ch.Check(ctx)
mu.Lock()
results[ch.Name()] = result
mu.Unlock()
}(checker)
}
wg.Wait()
return OverallResult{
Status: determineOverallStatus(results),
Timestamp: time.Now().UTC(),
Services: results,
}
}
func (c *CompositeChecker) CheckWithVersion(ctx context.Context, version string) OverallResult {
result := c.Check(ctx)
result.Version = version
return result
}

View File

@@ -0,0 +1,61 @@
package health
import (
"context"
"database/sql"
"time"
"goyco/internal/middleware"
)
type DatabaseChecker struct {
db *sql.DB
monitor middleware.DBMonitor
}
func NewDatabaseChecker(db *sql.DB, monitor middleware.DBMonitor) *DatabaseChecker {
return &DatabaseChecker{
db: db,
monitor: monitor,
}
}
func (c *DatabaseChecker) Name() string {
return "database"
}
func (c *DatabaseChecker) Check(ctx context.Context) Result {
start := time.Now()
err := c.db.Ping()
latency := time.Since(start)
result := Result{
Status: StatusHealthy,
Latency: latency,
Timestamp: time.Now().UTC(),
Details: map[string]any{
"ping_time": latency.String(),
},
}
if err != nil {
result.Status = StatusUnhealthy
result.Message = err.Error()
return result
}
if c.monitor != nil {
stats := c.monitor.GetStats()
result.Details["stats"] = map[string]any{
"total_queries": stats.TotalQueries,
"slow_queries": stats.SlowQueries,
"average_duration": stats.AverageDuration.String(),
"max_duration": stats.MaxDuration.String(),
"error_count": stats.ErrorCount,
"last_query_time": stats.LastQueryTime.Format(time.RFC3339),
}
}
return result
}

56
internal/health/health.go Normal file
View File

@@ -0,0 +1,56 @@
package health
import (
"context"
"time"
)
type Status string
const (
StatusHealthy Status = "healthy"
StatusDegraded Status = "degraded"
StatusUnhealthy Status = "unhealthy"
)
type Result struct {
Status Status `json:"status"`
Message string `json:"message,omitempty"`
Latency time.Duration `json:"latency"`
Timestamp time.Time `json:"timestamp"`
Details map[string]any `json:"details,omitempty"`
}
type Checker interface {
Name() string
Check(ctx context.Context) Result
}
type OverallResult struct {
Status Status `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
Services map[string]Result `json:"services"`
}
func determineOverallStatus(results map[string]Result) Status {
hasUnhealthy := false
hasDegraded := false
for _, result := range results {
switch result.Status {
case StatusUnhealthy:
hasUnhealthy = true
case StatusDegraded:
hasDegraded = true
}
}
if hasUnhealthy {
return StatusUnhealthy
}
if hasDegraded {
return StatusDegraded
}
return StatusHealthy
}

View File

@@ -0,0 +1,295 @@
package health
import (
"context"
"database/sql"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
"goyco/internal/middleware"
)
// MockDBMonitor is a mock implementation of DBMonitor for testing
type MockDBMonitor struct {
stats middleware.DBStats
}
func (m *MockDBMonitor) GetStats() middleware.DBStats {
return m.stats
}
func (m *MockDBMonitor) LogQuery(query string, duration time.Duration, err error) {
// Mock implementation - does nothing
}
func (m *MockDBMonitor) LogSlowQuery(query string, duration time.Duration, threshold time.Duration) {
// Mock implementation - does nothing
}
func TestStatusConstants(t *testing.T) {
if StatusHealthy != "healthy" {
t.Errorf("Expected StatusHealthy to be 'healthy', got %s", StatusHealthy)
}
if StatusDegraded != "degraded" {
t.Errorf("Expected StatusDegraded to be 'degraded', got %s", StatusDegraded)
}
if StatusUnhealthy != "unhealthy" {
t.Errorf("Expected StatusUnhealthy to be 'unhealthy', got %s", StatusUnhealthy)
}
}
func TestDetermineOverallStatus(t *testing.T) {
tests := []struct {
name string
results map[string]Result
expected Status
}{
{
name: "all healthy",
results: map[string]Result{"db": {Status: StatusHealthy}, "smtp": {Status: StatusHealthy}},
expected: StatusHealthy,
},
{
name: "one degraded",
results: map[string]Result{"db": {Status: StatusHealthy}, "smtp": {Status: StatusDegraded}},
expected: StatusDegraded,
},
{
name: "one unhealthy",
results: map[string]Result{"db": {Status: StatusUnhealthy}, "smtp": {Status: StatusHealthy}},
expected: StatusUnhealthy,
},
{
name: "mixed degraded and unhealthy",
results: map[string]Result{"db": {Status: StatusDegraded}, "smtp": {Status: StatusUnhealthy}},
expected: StatusUnhealthy,
},
{
name: "empty results",
results: map[string]Result{},
expected: StatusHealthy,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := determineOverallStatus(tt.results)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
func TestDatabaseChecker_Name(t *testing.T) {
checker := &DatabaseChecker{}
if checker.Name() != "database" {
t.Errorf("Expected name 'database', got %s", checker.Name())
}
}
func TestDatabaseChecker_Check(t *testing.T) {
// Create an in-memory database using the default driver
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
// Fallback: create a nil db and just verify the checker structure
t.Logf("Could not open test database: %v", err)
t.Skip("Skipping database-dependent test")
}
defer db.Close()
monitor := &MockDBMonitor{
stats: middleware.DBStats{
TotalQueries: 10,
SlowQueries: 1,
AverageDuration: 5 * time.Millisecond,
MaxDuration: 20 * time.Millisecond,
ErrorCount: 0,
LastQueryTime: time.Now(),
},
}
checker := NewDatabaseChecker(db, monitor)
ctx := context.Background()
result := checker.Check(ctx)
// Note: The actual result depends on database availability
// We just verify the structure is correct
if result.Timestamp.IsZero() {
t.Error("Expected non-zero timestamp")
}
if _, ok := result.Details["ping_time"]; !ok {
t.Error("Expected ping_time in details")
}
// Only check stats if status is healthy
if result.Status == StatusHealthy {
stats, ok := result.Details["stats"].(map[string]any)
if !ok {
t.Log("Stats not available in details (may be expected)")
} else {
if stats["total_queries"] != int64(10) {
t.Errorf("Expected total_queries to be 10, got %v", stats["total_queries"])
}
}
}
}
func TestDatabaseChecker_Check_Unhealthy(t *testing.T) {
// Test with a nil database connection
checker := NewDatabaseChecker(nil, nil)
ctx := context.Background()
// This should panic or return an error result
defer func() {
if r := recover(); r != nil {
t.Logf("Got expected panic with nil db: %v", r)
}
}()
result := checker.Check(ctx)
// If no panic, we should get an unhealthy status
if result.Status != StatusUnhealthy {
t.Logf("Expected unhealthy status for nil db, got %s", result.Status)
}
}
func TestSMTPChecker_Name(t *testing.T) {
checker := &SMTPChecker{}
if checker.Name() != "smtp" {
t.Errorf("Expected name 'smtp', got %s", checker.Name())
}
}
func TestSMTPChecker_Check_InvalidHost(t *testing.T) {
config := SMTPConfig{
Host: "invalid.host.that.does.not.exist",
Port: 587,
}
checker := NewSMTPChecker(config)
ctx := context.Background()
result := checker.Check(ctx)
if result.Status != StatusDegraded {
t.Errorf("Expected degraded status for invalid host, got %s", result.Status)
}
if result.Message == "" {
t.Error("Expected error message for connection failure")
}
if result.Details["host"] != "invalid.host.that.does.not.exist:587" {
t.Errorf("Expected host in details, got %v", result.Details["host"])
}
}
func TestSMTPChecker_Check_WithAuth(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "test@example.com",
Password: "password",
From: "noreply@example.com",
}
checker := NewSMTPChecker(config)
// Just verify the configuration is stored correctly
if checker.config.Host != "smtp.example.com" {
t.Errorf("Expected host to be 'smtp.example.com', got %s", checker.config.Host)
}
if checker.config.Port != 587 {
t.Errorf("Expected port to be 587, got %d", checker.config.Port)
}
}
func TestCompositeChecker(t *testing.T) {
// Create a composite checker with test checkers
checker1 := &mockChecker{
name: "service1",
status: StatusHealthy,
}
checker2 := &mockChecker{
name: "service2",
status: StatusHealthy,
}
composite := NewCompositeChecker(checker1, checker2)
ctx := context.Background()
result := composite.Check(ctx)
if result.Status != StatusHealthy {
t.Errorf("Expected overall healthy status, got %s", result.Status)
}
if len(result.Services) != 2 {
t.Errorf("Expected 2 service results, got %d", len(result.Services))
}
if _, ok := result.Services["service1"]; !ok {
t.Error("Expected service1 in results")
}
if _, ok := result.Services["service2"]; !ok {
t.Error("Expected service2 in results")
}
}
func TestCompositeChecker_AddChecker(t *testing.T) {
composite := NewCompositeChecker()
checker := &mockChecker{
name: "test-service",
status: StatusHealthy,
}
composite.AddChecker(checker)
ctx := context.Background()
result := composite.Check(ctx)
if len(result.Services) != 1 {
t.Errorf("Expected 1 service result, got %d", len(result.Services))
}
}
func TestCompositeChecker_CheckWithVersion(t *testing.T) {
checker := &mockChecker{
name: "test",
status: StatusHealthy,
}
composite := NewCompositeChecker(checker)
ctx := context.Background()
result := composite.CheckWithVersion(ctx, "v1.2.3")
if result.Version != "v1.2.3" {
t.Errorf("Expected version 'v1.2.3', got %s", result.Version)
}
}
// mockChecker is a test implementation of the Checker interface
type mockChecker struct {
name string
status Status
}
func (m *mockChecker) Name() string {
return m.name
}
func (m *mockChecker) Check(ctx context.Context) Result {
return Result{
Status: m.status,
Timestamp: time.Now().UTC(),
Latency: 1 * time.Millisecond,
}
}

106
internal/health/smtp.go Normal file
View File

@@ -0,0 +1,106 @@
package health
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"time"
)
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
}
type SMTPChecker struct {
config SMTPConfig
}
func NewSMTPChecker(config SMTPConfig) *SMTPChecker {
return &SMTPChecker{
config: config,
}
}
func (c *SMTPChecker) Name() string {
return "smtp"
}
func (c *SMTPChecker) Check(ctx context.Context) Result {
start := time.Now()
address := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
result := Result{
Status: StatusHealthy,
Timestamp: time.Now().UTC(),
Details: map[string]any{
"host": address,
},
}
conn, err := net.Dial("tcp", address)
if err != nil {
result.Status = StatusDegraded
result.Message = fmt.Sprintf("Failed to connect to SMTP server: %v", err)
result.Latency = time.Since(start)
return result
}
defer conn.Close()
client, err := smtp.NewClient(conn, c.config.Host)
if err != nil {
result.Status = StatusDegraded
result.Message = fmt.Sprintf("Failed to create SMTP client: %v", err)
result.Latency = time.Since(start)
return result
}
defer client.Close()
err = client.Hello("goyco-health-check")
if err != nil {
result.Status = StatusDegraded
result.Message = fmt.Sprintf("EHLO failed: %v", err)
result.Latency = time.Since(start)
return result
}
if ok, _ := client.Extension("STARTTLS"); ok {
tlsConfig := &tls.Config{
ServerName: c.config.Host,
}
err = client.StartTLS(tlsConfig)
if err != nil {
result.Details["starttls"] = "failed"
result.Details["starttls_error"] = err.Error()
} else {
result.Details["starttls"] = "enabled"
}
} else {
result.Details["starttls"] = "not supported"
}
if c.config.Username != "" && c.config.Password != "" {
auth := smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.Host)
err = client.Auth(auth)
if err != nil {
result.Details["auth"] = "failed"
result.Details["auth_error"] = err.Error()
} else {
result.Details["auth"] = "success"
}
} else {
result.Details["auth"] = "not configured"
}
client.Quit()
result.Latency = time.Since(start)
result.Details["handshake"] = "completed"
return result
}