diff --git a/README.md b/README.md index 49ddb0c..ec05757 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ If a "ping" does not arrive in the given interval & grace period, Heartbeats wil ## Flags ```yaml --c, --config string Path to the configuration file (default "./deploy/config.yaml") --l, --listen-address string Address to listen on (default "localhost:8080") +-c, --config string Path to the configuration file (default "./deploy/config.yaml") +-l, --listen-address string Address to listen on (default "localhost:8080") -s, --site-root string Site root for the heartbeat service (default "http://") --m, --max-size int Maximum size of the cache (default 100) --r, --reduce int Amount to reduce when max size is exceeded (default 10) --v, --verbose Enable verbose logging ---version Show version and exit --h, --help Show help and exit +-m, --max-size int Maximum size of the cache (default 1000) +-r, --reduce int Percentage to reduce when max size is exceeded (default 25) +-v, --verbose Enable verbose logging +--version Show version and exit +-h, --help Show help and exit ``` ## Environment Variables diff --git a/go.mod b/go.mod index 022ed08..7edb627 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.0 require ( github.com/Masterminds/sprig v2.22.0+incompatible + github.com/alecthomas/kingpin/v2 v2.4.0 github.com/prometheus/client_golang v1.19.1 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.11.0 @@ -15,6 +16,7 @@ require ( require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect + github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -28,6 +30,7 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect + github.com/xhit/go-str2duration/v2 v2.1.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 77c2d40..04d8783 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -49,9 +53,12 @@ github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNo github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -64,6 +71,7 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 0462028..4e083dc 100644 --- a/main.go +++ b/main.go @@ -5,48 +5,101 @@ import ( "embed" "fmt" "heartbeats/pkg/config" - "heartbeats/pkg/flags" + "heartbeats/pkg/heartbeat" + "heartbeats/pkg/history" "heartbeats/pkg/logger" + "heartbeats/pkg/notify" "heartbeats/pkg/server" "os" + + "github.com/alecthomas/kingpin/v2" ) -const version = "0.6.7" +const version = "0.6.10" //go:embed web var templates embed.FS -func run(ctx context.Context, verbose bool) error { - config.App.Version = version +var ( + configPath = kingpin.Flag("config", "Path to the configuration file"). + Short('c'). + Envar("HEARTBEATS_CONFIG"). + Default("./deploy/config.yaml"). + String() + listenAddress = kingpin.Flag("listen-address", "Address to listen on"). + Short('l'). + Envar("HEARTBEATS_LISTEN_ADDRESS"). + Default("localhost:8080"). + String() + siteRoot = kingpin. + Flag("site-root", "Site root for the heartbeat service"). + Short('s'). + Envar("HEARTBEATS_SITE_ROOT"). + Default("http://"). + String() + maxSize = kingpin.Flag("max-size", "Maximum size of the cache"). + Short('m'). + Envar("HEARTBEATS_MAX_SIZE"). + Default("1000"). + Int() + reduce = kingpin.Flag("reduce", "Percentage to reduce when max size is exceeded"). + Short('r'). + Envar("HEARTBEATS_REDUCE"). + Default("25"). + Int() + verbose = kingpin.Flag("verbose", "Enable verbose logging"). + Short('v'). + Envar("HEARTBEATS_VERBOSE"). + Bool() +) - result := flags.ParseFlags(os.Args, os.Stdout) +// run initializes and runs the server with the provided context and verbosity settings. +func run(ctx context.Context, verbose bool) error { + kingpin.CommandLine.Name = "heartbeats" + kingpin.UsageTemplate(kingpin.CompactUsageTemplate) + kingpin.Parse() - if result.Err != nil { - fmt.Fprintf(os.Stderr, "error parsing flags: %v\n", result.Err) - os.Exit(1) - } + log := logger.NewLogger(verbose) - if result.ShowHelp { - os.Exit(0) - } + heartbeatsStore := heartbeat.NewStore() + notificationStore := notify.NewStore() + historyStore := history.NewStore() - if result.ShowVersion { - fmt.Println(version) - os.Exit(0) + if err := config.Read( + *configPath, + history.Config{ + MaxSize: *maxSize, + Reduce: *reduce, + }, + heartbeatsStore, + notificationStore, + historyStore, + ); err != nil { + return fmt.Errorf("error reading config file. %v", err) } - log := logger.NewLogger(verbose) - - if err := config.App.Read(); err != nil { - return fmt.Errorf("Error reading config file: %v", err) + if err := config.Validate(heartbeatsStore, notificationStore); err != nil { + return fmt.Errorf("Error validating config file. %v", err) } - return server.Run(ctx, config.App.Server.ListenAddress, templates, log) + return server.Run( + ctx, + log, + version, + server.Config{ + ListenAddress: *listenAddress, + SiteRoot: *siteRoot, + }, + templates, + heartbeatsStore, + notificationStore, + historyStore, + ) } func main() { ctx := context.Background() - if err := run(ctx, config.App.Verbose); err != nil { + if err := run(ctx, *verbose); err != nil { fmt.Fprintf(os.Stderr, "%s\n", err) os.Exit(1) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 25484a6..cbbfecd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,41 +10,9 @@ import ( "gopkg.in/yaml.v3" ) -// App is the global configuration instance. -var App = &Config{ - HeartbeatStore: heartbeat.NewStore(), - NotificationStore: notify.NewStore(), -} - -// HistoryStore is the global HistoryStore. -var HistoryStore = history.NewStore() - -// Cache configuration structure. -type Cache struct { - MaxSize int `yaml:"maxSize"` // Maximum size of the cache - Reduce int `yaml:"reduce"` // Amount to reduce when max size is exceeded -} - -// Server configuration structure. -type Server struct { - SiteRoot string `yaml:"siteRoot"` // Site root - ListenAddress string `yaml:"listenAddress"` // Address on which the application listens -} - -// Config holds the entire application configuration. -type Config struct { - Version string `yaml:"version"` - Verbose bool `yaml:"verbose"` - Path string `yaml:"path"` - Server Server `yaml:"server"` - Cache Cache `yaml:"cache"` - HeartbeatStore *heartbeat.Store `yaml:"heartbeats"` - NotificationStore *notify.Store `yaml:"notifications"` -} - -// Read reads the configuration from the file specified in the Config struct. -func (c *Config) Read() error { - content, err := os.ReadFile(c.Path) +// Read reads the configuration from a specified file and processes it. +func Read(path string, historyConfig history.Config, heartbeatsStore *heartbeat.Store, notificationStore *notify.Store, historyStore *history.Store) error { + content, err := os.ReadFile(path) if err != nil { return fmt.Errorf("failed to read config file. %w", err) } @@ -54,19 +22,32 @@ func (c *Config) Read() error { return fmt.Errorf("failed to unmarshal raw config. %w", err) } - if err := c.processNotifications(rawConfig["notifications"]); err != nil { + if err := processNotifications(rawConfig["notifications"], notificationStore); err != nil { + return err + } + + if err := processHeartbeats(rawConfig["heartbeats"], heartbeatsStore, historyStore, historyConfig.MaxSize, historyConfig.Reduce); err != nil { + return err + } + + return nil +} + +// Validate validates the configuration file. +func Validate(heartbeatStore *heartbeat.Store, notificationStore *notify.Store) error { + if err := validateNotifications(notificationStore); err != nil { return err } - if err := c.processHeartbeats(rawConfig["heartbeats"]); err != nil { + if err := validateHeartbeats(heartbeatStore, notificationStore); err != nil { return err } return nil } -// processNotifications handles the unmarshaling and processing of notification configurations. -func (c *Config) processNotifications(rawNotifications interface{}) error { +// processNotifications processes the raw notification configurations and updates the notification store. +func processNotifications(rawNotifications interface{}, notificationStore *notify.Store) error { notifications, ok := rawNotifications.(map[string]interface{}) if !ok { return fmt.Errorf("failed to unmarshal notifications") @@ -83,11 +64,11 @@ func (c *Config) processNotifications(rawNotifications interface{}) error { return fmt.Errorf("failed to unmarshal notification '%s'. %w", name, err) } - if err := c.NotificationStore.Add(name, ¬ification); err != nil { + if err := notificationStore.Add(name, ¬ification); err != nil { return fmt.Errorf("failed to add notification '%s'. %w", name, err) } - if err := c.updateSlackNotification(name, ¬ification); err != nil { + if err := updateSlackNotification(name, ¬ification, notificationStore); err != nil { return err } } @@ -95,24 +76,23 @@ func (c *Config) processNotifications(rawNotifications interface{}) error { return nil } -// updateSlackNotification updates the Slack notification with a default color template if not set. -func (c *Config) updateSlackNotification(name string, notification *notify.Notification) error { +// updateSlackNotification sets a default color template for Slack notifications if not provided and updates the notification store. +func updateSlackNotification(name string, notification *notify.Notification, notificationStore *notify.Store) error { if notification.Type == "slack" && notification.SlackConfig.ColorTemplate == "" { notification.SlackConfig.ColorTemplate = `{{ if eq .Status "ok" }}good{{ else }}danger{{ end }}` - if err := c.NotificationStore.Update(name, notification); err != nil { + if err := notificationStore.Update(name, notification); err != nil { return fmt.Errorf("failed to update notification '%s'. %w", notification.Name, err) } } return nil } -// processHeartbeats handles the unmarshaling and processing of heartbeat configurations. -func (c *Config) processHeartbeats(rawHeartbeats interface{}) error { +// processHeartbeats processes and adds heartbeats to the store, creating their respective histories. +func processHeartbeats(rawHeartbeats interface{}, heartbeatStore *heartbeat.Store, historyStore *history.Store, maxSize, reduce int) error { heartbeats, ok := rawHeartbeats.(map[string]interface{}) if !ok { return fmt.Errorf("failed to unmarshal heartbeats") } - for name, rawHeartbeat := range heartbeats { heartbeatBytes, err := yaml.Marshal(rawHeartbeat) if err != nil { @@ -124,19 +104,48 @@ func (c *Config) processHeartbeats(rawHeartbeats interface{}) error { return fmt.Errorf("failed to unmarshal heartbeat '%s'. %w", name, err) } - if err := c.HeartbeatStore.Add(name, &hb); err != nil { + if err := heartbeatStore.Add(name, &hb); err != nil { return fmt.Errorf("failed to add heartbeat '%s'. %w", hb.Name, err) } - historyInstance, err := history.NewHistory(c.Cache.MaxSize, c.Cache.Reduce) + historyInstance, err := history.NewHistory(maxSize, reduce) if err != nil { - return fmt.Errorf("failed to add heartbeat '%s'. %w", hb.Name, err) + return fmt.Errorf("failed to create history for heartbeat '%s'. %w", name, err) } - if err := HistoryStore.Add(name, historyInstance); err != nil { + if err := historyStore.Add(name, historyInstance); err != nil { return fmt.Errorf("failed to create heartbeat history for '%s'. %w", name, err) } } return nil } + +// validateNotifications validates the notification configurations. +func validateNotifications(notificationStore *notify.Store) error { + var hbDummy heartbeat.Heartbeat + for _, notification := range notificationStore.GetAll() { + if notification.Enabled != nil && !*notification.Enabled { + continue + } + + if err := notification.ValidateTemplate(&hbDummy); err != nil { + return fmt.Errorf("cannot validate templates. %s", err) + } + } + + return nil +} + +// validateHeartbeats validates the heartbeat configurations. +func validateHeartbeats(heartbeatStore *heartbeat.Store, notificationStore *notify.Store) error { + for name, heartbeat := range heartbeatStore.GetAll() { + for _, notification := range heartbeat.Notifications { + if exists := notificationStore.Get(notification); exists == nil { + return fmt.Errorf("notification '%s' not found for heartbeat '%s'.", notification, name) + } + } + } + + return nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2467cd9..a819db1 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -5,114 +5,261 @@ import ( "heartbeats/pkg/history" "heartbeats/pkg/notify" "heartbeats/pkg/notify/notifier" + "heartbeats/pkg/timer" "os" "testing" + "time" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) -// Sample configuration YAML for testing. -const sampleConfig = ` -version: "1.0.0" -verbose: true -path: "./config.yaml" -server: - siteRoot: "http://localhost:8080" - listenAddress: "localhost:8080" -cache: - maxSize: 100 - reduce: 10 +func TestRead(t *testing.T) { + sampleConfig := ` +heartbeats: + test_heartbeat: + enabled: true + interval: 1m + grace: 1m + notifications: ["test_notification"] + notifications: - slack: - type: "slack" + test_notification: slack_config: - channel: "general" -heartbeats: - heartbeat1: - name: "heartbeat1" - interval: "1m" - grace: "1m" - notifications: - - slack + channel: general + ` + tmpFile, err := os.CreateTemp("", "config.yaml") + assert.NoError(t, err) + defer os.Remove(tmpFile.Name()) -func writeSampleConfig(t *testing.T, content string) string { - file, err := os.CreateTemp("", "config*.yaml") - if err != nil { - t.Fatalf("Failed to create temp file: %v", err) - } - defer file.Close() + _, err = tmpFile.Write([]byte(sampleConfig)) + assert.NoError(t, err) + err = tmpFile.Close() + assert.NoError(t, err) + + heartbeatsStore := heartbeat.NewStore() + notificationStore := notify.NewStore() + historyStore := history.NewStore() - if _, err := file.WriteString(content); err != nil { - t.Fatalf("Failed to write to temp file: %v", err) + historyConfig := history.Config{ + MaxSize: 110, + Reduce: 25, } - return file.Name() + t.Run("Read config file and process notifications and heartbeats", func(t *testing.T) { + err := Read(tmpFile.Name(), historyConfig, heartbeatsStore, notificationStore, historyStore) + assert.NoError(t, err) + + // Verify heartbeats + hb := heartbeatsStore.Get("test_heartbeat") + assert.NotNil(t, hb) + assert.Equal(t, "test_heartbeat", hb.Name) + assert.NotNil(t, hb.Interval) + assert.NotNil(t, hb.Grace) + assert.Len(t, hb.Notifications, 1) + assert.Equal(t, "test_notification", hb.Notifications[0]) + + // Verify notifications + n := notificationStore.Get("test_notification") + assert.NotNil(t, n) + assert.Equal(t, "test_notification", n.Name) + assert.Equal(t, "slack", n.Type) + + // Verify history + h := historyStore.Get("test_heartbeat") + assert.NotNil(t, h) + assert.Equal(t, 110, historyConfig.MaxSize) + assert.Equal(t, 25, historyConfig.Reduce) + }) } -func TestConfig_Read(t *testing.T) { - App.NotificationStore = notify.NewStore() - HistoryStore = history.NewStore() +func TestProcessNotifications(t *testing.T) { + sampleConfig := ` +notifications: + test_notification: + slack_config: + channel: general - tempFile := writeSampleConfig(t, sampleConfig) - defer os.Remove(tempFile) +` - App.Path = tempFile + notificationStore := notify.NewStore() - err := App.Read() - assert.NoError(t, err, "Expected no error when reading the config file") + t.Run("Process valid notifications", func(t *testing.T) { + var rawConfig map[string]interface{} + err := yaml.Unmarshal([]byte(sampleConfig), &rawConfig) + assert.NoError(t, err) - notification := App.NotificationStore.Get("slack") - assert.NotNil(t, notification, "Expected slack notification to be present") - assert.Equal(t, `{{ if eq .Status "ok" }}good{{ else }}danger{{ end }}`, notification.SlackConfig.ColorTemplate) + err = processNotifications(rawConfig["notifications"], notificationStore) + assert.NoError(t, err) - heartbeat := App.HeartbeatStore.Get("heartbeat1") - assert.NotNil(t, heartbeat, "Expected heartbeat1 to be present") - assert.Equal(t, "heartbeat1", heartbeat.Name) + // Verify notification + n := notificationStore.Get("test_notification") + assert.NotNil(t, n) + assert.Equal(t, "test_notification", n.Name) + assert.Equal(t, "slack", n.Type) + }) } -func TestProcessNotifications(t *testing.T) { - App.NotificationStore = notify.NewStore() - HistoryStore = history.NewStore() +func TestProcessHeartbeats(t *testing.T) { + sampleConfig := ` +heartbeats: + test_heartbeat: + enabled: true + interval: 1m + grace: 1m + notifications: ["test_notification"] - var rawConfig map[string]interface{} - err := yaml.Unmarshal([]byte(sampleConfig), &rawConfig) - assert.NoError(t, err) - err = App.processNotifications(rawConfig["notifications"]) - assert.NoError(t, err, "Expected no error when processing notifications") +notifications: + test_notification: + slack_config: + channel: general + +` + + heartbeatsStore := heartbeat.NewStore() + historyStore := history.NewStore() + + maxSize := 120 + reduce := 25 + + t.Run("Process valid heartbeats", func(t *testing.T) { + var rawConfig map[string]interface{} + err := yaml.Unmarshal([]byte(sampleConfig), &rawConfig) + assert.NoError(t, err) - notification := App.NotificationStore.Get("slack") - assert.NotNil(t, notification, "Expected slack notification to be present") - assert.Equal(t, "slack", notification.Type) - assert.Equal(t, `{{ if eq .Status "ok" }}good{{ else }}danger{{ end }}`, notification.SlackConfig.ColorTemplate) + err = processHeartbeats(rawConfig["heartbeats"], heartbeatsStore, historyStore, maxSize, reduce) + assert.NoError(t, err) + + // Verify heartbeat + hb := heartbeatsStore.Get("test_heartbeat") + assert.NotNil(t, hb) + assert.Equal(t, "test_heartbeat", hb.Name) + assert.NotNil(t, hb.Interval) + assert.NotNil(t, hb.Grace) + assert.Len(t, hb.Notifications, 1) + assert.Equal(t, "test_notification", hb.Notifications[0]) + + // Verify history + h := historyStore.Get("test_heartbeat") + assert.NotNil(t, h) + assert.Equal(t, 120, maxSize) + assert.Equal(t, 25, reduce) + }) } -func TestProcessHeartbeats(t *testing.T) { - App.HeartbeatStore = heartbeat.NewStore() - HistoryStore = history.NewStore() +func TestValidateNotifications(t *testing.T) { + t.Run("Validate valid notification", func(t *testing.T) { + ns := notify.NewStore() + err := ns.Add("valid_notification", ¬ify.Notification{ + Type: "slack", + SlackConfig: ¬ifier.SlackConfig{ + Channel: "general", + Title: "title", + Text: "{{ .Name }} is {{ .Status }}", + }, + }) + assert.NoError(t, err) - var rawConfig map[string]interface{} - err := yaml.Unmarshal([]byte(sampleConfig), &rawConfig) - assert.NoError(t, err) + err = validateNotifications(ns) + assert.NoError(t, err) + }) + + t.Run("Validate valid and invalid notifications", func(t *testing.T) { + ns := notify.NewStore() + err := ns.Add("valid_notification", ¬ify.Notification{ + Type: "slack", + SlackConfig: ¬ifier.SlackConfig{ + Channel: "general", + Title: "title", + Text: "{{ .Name }} is {{ .Status }}", + }, + }) + assert.NoError(t, err) - err = App.processHeartbeats(rawConfig["heartbeats"]) - assert.NoError(t, err, "Expected no error when processing heartbeats") + err = ns.Add("invalid_notification", ¬ify.Notification{ + Type: "slack", + SlackConfig: ¬ifier.SlackConfig{ + Channel: "general", + Title: "title", + Text: "{{ .InvalidField }} is {{ .Status }}", + }, + }) + assert.NoError(t, err) - heartbeat := App.HeartbeatStore.Get("heartbeat1") - assert.NotNil(t, heartbeat, "Expected heartbeat1 to be present") - assert.Equal(t, "heartbeat1", heartbeat.Name) + err = validateNotifications(ns) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot validate templates. cannot validate Slack text template. error executing template. template: text:1:3: executing \"text\" at <.InvalidField>: can't evaluate field InvalidField in type *heartbeat.Heartbeat") + }) } -func TestUpdateSlackNotification(t *testing.T) { - notification := ¬ify.Notification{ +func TestValidateHeartbeats(t *testing.T) { + ns := notify.NewStore() + err := ns.Add("valid_notification", ¬ify.Notification{ Type: "slack", SlackConfig: ¬ifier.SlackConfig{ Channel: "general", + Text: "{{ .Name }} is {{ .Status }}", }, + }) + assert.NoError(t, err) + + i, err := time.ParseDuration("1m") + assert.NoError(t, err) + + timerDummy := timer.Timer{ + Interval: &i, } - err := App.updateSlackNotification("slack", notification) - assert.NoError(t, err, "Expected no error when updating slack notification") - assert.Equal(t, `{{ if eq .Status "ok" }}good{{ else }}danger{{ end }}`, notification.SlackConfig.ColorTemplate) + t.Run("Validate valid heartbeats", func(t *testing.T) { + hs := heartbeat.NewStore() + err := hs.Add("valid_heartbeat", &heartbeat.Heartbeat{ + Name: "valid_heartbeat", + Interval: &timerDummy, + Grace: &timerDummy, + Notifications: []string{"valid_notification"}, + }) + assert.NoError(t, err) + + err = validateHeartbeats(hs, ns) + assert.NoError(t, err) + }) + + t.Run("Validate valid and invalid interval heartbeats", func(t *testing.T) { + hs := heartbeat.NewStore() + err := hs.Add("valid_heartbeat", &heartbeat.Heartbeat{ + Name: "valid_heartbeat", + Interval: &timerDummy, + Grace: &timerDummy, + Notifications: []string{"valid_notification"}, + }) + assert.NoError(t, err) + + err = hs.Add("invalid_heartbeat", &heartbeat.Heartbeat{ + Name: "invalid_heartbeat", + Interval: &timerDummy, + Grace: &timerDummy, + Notifications: []string{"invalid_notification"}, + }) + assert.NoError(t, err) + + err = validateHeartbeats(hs, ns) + assert.Error(t, err) + assert.Contains(t, err.Error(), "notification 'invalid_notification' not found for heartbeat 'invalid_heartbeat'.") + }) + + t.Run("Validate invalid notification", func(t *testing.T) { + hs := heartbeat.NewStore() + err := hs.Add("valid_heartbeat", &heartbeat.Heartbeat{ + Name: "valid_heartbeat", + Interval: &timerDummy, + Grace: &timerDummy, + Notifications: []string{"invalid_notification"}, + }) + assert.NoError(t, err) + + err = validateHeartbeats(hs, ns) + assert.Error(t, err) + assert.Contains(t, err.Error(), "notification 'invalid_notification' not found for heartbeat 'valid_heartbeat'.") + }) } diff --git a/pkg/flags/flags.go b/pkg/flags/flags.go deleted file mode 100644 index e973b59..0000000 --- a/pkg/flags/flags.go +++ /dev/null @@ -1,77 +0,0 @@ -package flags - -import ( - "fmt" - "heartbeats/pkg/config" - "io" - "os" - "strings" - - "github.com/spf13/pflag" -) - -// ParseResult contains the result of the ParseFlags function. -type ParseResult struct { - ShowHelp bool - ShowVersion bool - Err error -} - -// ParseFlags initializes the command-line flags and sets the values in the global config.App. -func ParseFlags(args []string, output io.Writer) ParseResult { - var showVersion, showHelp bool - - pflag.StringVarP(&config.App.Path, "config", "c", "./deploy/config.yaml", "Path to the configuration file") - pflag.StringVarP(&config.App.Server.ListenAddress, "listen-address", "l", "localhost:8080", "Address to listen on") - pflag.StringVarP(&config.App.Server.SiteRoot, "site-root", "s", "", "Site root for the heartbeat service (default \"http://\")") - pflag.IntVarP(&config.App.Cache.MaxSize, "max-size", "m", 100, "Maximum size of the cache") - pflag.IntVarP(&config.App.Cache.Reduce, "reduce", "r", 10, "Amount to reduce when max size is exceeded") - pflag.BoolVarP(&config.App.Verbose, "verbose", "v", false, "Enable verbose logging") - pflag.BoolVar(&showVersion, "version", false, "Show version and exit") - pflag.BoolVarP(&showHelp, "help", "h", false, "Show help and exit") - - pflag.CommandLine.SetOutput(output) - pflag.CommandLine.SortFlags = false - pflag.CommandLine.Init("heartbeats", pflag.ExitOnError) - - // Disable default help flag - pflag.CommandLine.Usage = func() { - fmt.Fprintf(output, "Usage of %s:\n", args[0]) - pflag.PrintDefaults() - } - - processEnvVariables() - - err := pflag.CommandLine.Parse(args[1:]) - if err != nil { - return ParseResult{Err: err} - } - - if showHelp { - pflag.Usage() - return ParseResult{ShowHelp: true} - } - - if showVersion { - return ParseResult{ShowVersion: true} - } - - if config.App.Server.SiteRoot == "" { - config.App.Server.SiteRoot = fmt.Sprintf("http://%s", config.App.Server.ListenAddress) - } - - return ParseResult{} -} - -// processEnvVariables checks for environment variables with the prefix "HEARTBEATS_" and sets the corresponding flags. -func processEnvVariables() { - prefix := "HEARTBEATS_" - pflag.VisitAll(func(f *pflag.Flag) { - envVar := prefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) - if val, ok := os.LookupEnv(envVar); ok { - if err := f.Value.Set(val); err != nil { - fmt.Fprintf(os.Stderr, "Error setting flag from environment variable %s: %v\n", envVar, err) - } - } - }) -} diff --git a/pkg/flags/flags_test.go b/pkg/flags/flags_test.go deleted file mode 100644 index f799a4a..0000000 --- a/pkg/flags/flags_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package flags - -import ( - "heartbeats/pkg/config" - "os" - "strings" - "testing" - - "github.com/spf13/pflag" - "github.com/stretchr/testify/assert" -) - -func resetFlags() { - pflag.CommandLine = pflag.NewFlagSet("heartbeats", pflag.ExitOnError) -} - -func TestParseFlags(t *testing.T) { - resetFlags() - - output := &strings.Builder{} - args := []string{"cmd", "-c", "config.yaml", "-l", "127.0.0.1:9090", "-s", "http://example.com", "-m", "200", "-r", "20", "-v"} - - result := ParseFlags(args, output) - assert.NoError(t, result.Err) - assert.Equal(t, result.ShowVersion, false) - assert.Equal(t, result.ShowHelp, false) - - assert.Equal(t, "config.yaml", config.App.Path) - assert.Equal(t, "127.0.0.1:9090", config.App.Server.ListenAddress) - assert.Equal(t, "http://example.com", config.App.Server.SiteRoot) - assert.Equal(t, 200, config.App.Cache.MaxSize) - assert.Equal(t, 20, config.App.Cache.Reduce) - assert.True(t, config.App.Verbose) -} - -func TestShowVersionFlag(t *testing.T) { - resetFlags() - - output := &strings.Builder{} - args := []string{"cmd", "--version"} - result := ParseFlags(args, output) - assert.NoError(t, result.Err) - assert.Equal(t, result.ShowVersion, true) - assert.Equal(t, result.ShowHelp, false) -} - -func TestShowHelpFlag(t *testing.T) { - resetFlags() - - output := &strings.Builder{} - args := []string{"cmd", "--help"} - - result := ParseFlags(args, output) - assert.NoError(t, result.Err) - assert.Equal(t, result.ShowVersion, false) - assert.Equal(t, result.ShowHelp, true) -} - -func TestProcessEnvVariables(t *testing.T) { - resetFlags() - - os.Setenv("HEARTBEATS_CONFIG", "env_config.yaml") - os.Setenv("HEARTBEATS_LISTEN_ADDRESS", "0.0.0.0:8080") - os.Setenv("HEARTBEATS_SITE_ROOT", "http://env.com") - os.Setenv("HEARTBEATS_MAX_SIZE", "300") - os.Setenv("HEARTBEATS_REDUCE", "30") - os.Setenv("HEARTBEATS_VERBOSE", "true") - - pflag.StringVarP(&config.App.Path, "config", "c", "./deploy/config.yaml", "Path to the configuration file") - pflag.StringVarP(&config.App.Server.ListenAddress, "listen-address", "l", "localhost:8080", "Address to listen on") - pflag.StringVarP(&config.App.Server.SiteRoot, "site-root", "s", "", "Site root for the heartbeat service (default \"http://\")") - pflag.IntVarP(&config.App.Cache.MaxSize, "max-size", "m", 100, "Maximum size of the cache") - pflag.IntVarP(&config.App.Cache.Reduce, "reduce", "r", 10, "Amount to reduce when max size is exceeded") - pflag.BoolVarP(&config.App.Verbose, "verbose", "v", false, "Enable verbose logging") - - pflag.Parse() - - processEnvVariables() - - assert.Equal(t, "env_config.yaml", config.App.Path) - assert.Equal(t, "0.0.0.0:8080", config.App.Server.ListenAddress) - assert.Equal(t, "http://env.com", config.App.Server.SiteRoot) - assert.Equal(t, 300, config.App.Cache.MaxSize) - assert.Equal(t, 30, config.App.Cache.Reduce) - assert.True(t, config.App.Verbose) -} diff --git a/pkg/handlers/heartbeats.go b/pkg/handlers/heartbeats.go index bb27baf..e9ee978 100644 --- a/pkg/handlers/heartbeats.go +++ b/pkg/handlers/heartbeats.go @@ -1,8 +1,9 @@ package handlers import ( - "heartbeats/pkg/config" + "heartbeats/pkg/heartbeat" "heartbeats/pkg/logger" + "heartbeats/pkg/notify" "heartbeats/pkg/timer" "html/template" "io/fs" @@ -37,7 +38,7 @@ type NotificationState struct { } // Heartbeats handles the / endpoint -func Heartbeats(logger logger.Logger, staticFS fs.FS) http.HandlerFunc { +func Heartbeats(logger logger.Logger, staticFS fs.FS, version, siteRoot string, heartbeatStore *heartbeat.Store, notificationStore *notify.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { fmap := sprig.TxtFuncMap() fmap["isTrue"] = isTrue @@ -57,9 +58,6 @@ func Heartbeats(logger logger.Logger, staticFS fs.FS) http.HandlerFunc { return } - heartbeatStore := config.App.HeartbeatStore - notificationStore := config.App.NotificationStore - var heartbeatDataList []*HeartbeatData for _, h := range heartbeatStore.GetAll() { var notifications []NotificationState @@ -84,8 +82,8 @@ func Heartbeats(logger logger.Logger, staticFS fs.FS) http.HandlerFunc { } data := HeartbeatPageData{ - Version: config.App.Version, - SiteRoot: config.App.Server.SiteRoot, + Version: version, + SiteRoot: siteRoot, Heartbeats: heartbeatDataList, } diff --git a/pkg/handlers/heartbeats_test.go b/pkg/handlers/heartbeats_test.go index 5ee215d..4eebdff 100644 --- a/pkg/handlers/heartbeats_test.go +++ b/pkg/handlers/heartbeats_test.go @@ -1,9 +1,7 @@ package handlers import ( - "heartbeats/pkg/config" "heartbeats/pkg/heartbeat" - "heartbeats/pkg/history" "heartbeats/pkg/logger" "heartbeats/pkg/notify" "heartbeats/pkg/notify/notifier" @@ -44,9 +42,11 @@ func setupAferoFSForHeartbeats() afero.Fs { func TestHeartbeatsHandler(t *testing.T) { log := logger.NewLogger(true) - config.App.HeartbeatStore = heartbeat.NewStore() - config.App.NotificationStore = notify.NewStore() - config.HistoryStore = history.NewStore() + + heartbeatStore := heartbeat.NewStore() + notificationStore := notify.NewStore() + version := "1.0.0" + siteRoot := "http://localhost:8080" h := &heartbeat.Heartbeat{ Name: "test", @@ -59,7 +59,7 @@ func TestHeartbeatsHandler(t *testing.T) { *h.Interval.Interval = time.Minute *h.Grace.Interval = time.Minute - err := config.App.HeartbeatStore.Add("test", h) + err := heartbeatStore.Add("test", h) assert.NoError(t, err) ns := ¬ify.Notification{ @@ -70,13 +70,13 @@ func TestHeartbeatsHandler(t *testing.T) { } *ns.Enabled = false - err = config.App.NotificationStore.Add("test", ns) + err = notificationStore.Add("test", ns) assert.NoError(t, err) aferoFS := setupAferoFSForHeartbeats() mux := http.NewServeMux() - mux.Handle("/", Heartbeats(log, aferoToCustomAferoFS(aferoFS))) + mux.Handle("/", Heartbeats(log, aferoToCustomAferoFS(aferoFS), version, siteRoot, heartbeatStore, notificationStore)) t.Run("Heartbeat page renders correctly", func(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) @@ -93,8 +93,7 @@ func TestHeartbeatsHandler(t *testing.T) { t.Run("Template parsing error", func(t *testing.T) { invalidFS := afero.NewMemMapFs() // Empty FS to simulate missing templates mux := http.NewServeMux() - mux.Handle("/", Heartbeats(log, aferoToCustomAferoFS(invalidFS))) - + mux.Handle("/", Heartbeats(log, aferoToCustomAferoFS(invalidFS), version, siteRoot, heartbeatStore, notificationStore)) req := httptest.NewRequest("GET", "/", nil) rec := httptest.NewRecorder() diff --git a/pkg/handlers/history.go b/pkg/handlers/history.go index 256e456..85a3597 100644 --- a/pkg/handlers/history.go +++ b/pkg/handlers/history.go @@ -2,7 +2,7 @@ package handlers import ( "fmt" - "heartbeats/pkg/config" + "heartbeats/pkg/heartbeat" "heartbeats/pkg/history" "heartbeats/pkg/logger" "html/template" @@ -11,12 +11,14 @@ import ( ) // History handles the /history/{id} endpoint -func History(logger logger.Logger, staticFS fs.FS) http.Handler { +func History(logger logger.Logger, staticFS fs.FS, version string, heartbeatStore *heartbeat.Store, historyStore *history.Store) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { heartbeatName := r.PathValue("id") - logger.Debugf("%s /history/%s %s %s", r.Method, heartbeatName, r.RemoteAddr, r.UserAgent()) - h := config.App.HeartbeatStore.Get(heartbeatName) + clientIP := getClientIP(r) + logger.Debugf("%s /history/%s %s %s", r.Method, heartbeatName, clientIP, r.UserAgent()) + + h := heartbeatStore.Get(heartbeatName) if h == nil { errMsg := fmt.Sprintf("Heartbeat '%s' not found", heartbeatName) logger.Warn(errMsg) @@ -24,6 +26,14 @@ func History(logger logger.Logger, staticFS fs.FS) http.Handler { return } + hi := historyStore.Get(heartbeatName) + if hi == nil { + errMsg := fmt.Sprintf("No history found for heartbeat '%s'", heartbeatName) + logger.Warn(errMsg) + http.Error(w, errMsg, http.StatusNotFound) + return + } + fmap := template.FuncMap{ "formatTime": formatTime, } @@ -46,9 +56,9 @@ func History(logger logger.Logger, staticFS fs.FS) http.Handler { Name string Entries []history.HistoryEntry }{ - Version: config.App.Version, + Version: version, Name: heartbeatName, - Entries: config.HistoryStore.Get(heartbeatName).GetAllEntries(), + Entries: hi.GetAllEntries(), } if err := tmpl.ExecuteTemplate(w, "history", data); err != nil { diff --git a/pkg/handlers/history_test.go b/pkg/handlers/history_test.go index cfc5be9..c4fdf94 100644 --- a/pkg/handlers/history_test.go +++ b/pkg/handlers/history_test.go @@ -1,11 +1,9 @@ package handlers import ( - "heartbeats/pkg/config" "heartbeats/pkg/heartbeat" "heartbeats/pkg/history" "heartbeats/pkg/logger" - "heartbeats/pkg/notify" "heartbeats/pkg/timer" "io/fs" "net/http" @@ -59,9 +57,8 @@ func aferoToCustomAferoFS(afs afero.Fs) fs.FS { func TestHistoryHandler(t *testing.T) { log := logger.NewLogger(true) - config.App.HeartbeatStore = heartbeat.NewStore() - config.App.NotificationStore = notify.NewStore() - config.HistoryStore = history.NewStore() + heartbeatStore := heartbeat.NewStore() + historyStore := history.NewStore() h := &heartbeat.Heartbeat{ Name: "test", @@ -73,21 +70,20 @@ func TestHistoryHandler(t *testing.T) { *h.Interval.Interval = time.Minute *h.Grace.Interval = time.Minute - err := config.App.HeartbeatStore.Add("test", h) + err := heartbeatStore.Add("test", h) assert.NoError(t, err) hist, err := history.NewHistory(10, 2) assert.NoError(t, err) - err = config.HistoryStore.Add("test", hist) + err = historyStore.Add("test", hist) assert.NoError(t, err) - ns := notify.NewStore() - config.App.NotificationStore = ns + version := "1.0.0" mux := http.NewServeMux() aferoFS := setupAferoFSForHistory() - mux.Handle("GET /history/{id}", History(log, aferoToCustomAferoFS(aferoFS))) + mux.Handle("GET /history/{id}", History(log, aferoToCustomAferoFS(aferoFS), version, heartbeatStore, historyStore)) t.Run("Heartbeat not found", func(t *testing.T) { req := httptest.NewRequest("GET", "/history/nonexistent", nil) @@ -113,7 +109,7 @@ func TestHistoryHandler(t *testing.T) { t.Run("Template parsing error", func(t *testing.T) { invalidFS := afero.NewMemMapFs() // Empty FS to simulate missing templates mux := http.NewServeMux() - mux.Handle("GET /history/{id}", History(log, aferoToCustomAferoFS(invalidFS))) + mux.Handle("GET /history/{id}", History(log, aferoToCustomAferoFS(invalidFS), version, heartbeatStore, historyStore)) req := httptest.NewRequest("GET", "/history/test", nil) rec := httptest.NewRecorder() diff --git a/pkg/handlers/ping.go b/pkg/handlers/ping.go index 3bc22ab..f4c898a 100644 --- a/pkg/handlers/ping.go +++ b/pkg/handlers/ping.go @@ -3,14 +3,15 @@ package handlers import ( "context" "fmt" - "heartbeats/pkg/config" + "heartbeats/pkg/heartbeat" "heartbeats/pkg/history" "heartbeats/pkg/logger" + "heartbeats/pkg/notify" "net/http" ) // Ping handles the /ping/{id} endpoint -func Ping(logger logger.Logger) http.Handler { +func Ping(logger logger.Logger, heartbeatStore *heartbeat.Store, notificationStore *notify.Store, historyStore *history.Store) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // Use context.Background() to ensure the timers are not tied to the HTTP request context. @@ -21,7 +22,7 @@ func Ping(logger logger.Logger) http.Handler { clientIP := getClientIP(r) logger.Debugf("%s /ping/%s %s %s", r.Method, heartbeatName, clientIP, r.UserAgent()) - h := config.App.HeartbeatStore.Get(heartbeatName) + h := heartbeatStore.Get(heartbeatName) if h == nil { errMsg := fmt.Sprintf("Heartbeat '%s' not found", heartbeatName) logger.Warn(errMsg) @@ -39,16 +40,15 @@ func Ping(logger logger.Logger) http.Handler { msg := "got ping" logger.Infof("%s %s", heartbeatName, msg) - hs := config.HistoryStore.Get(heartbeatName) - hs.AddEntry(history.Beat, msg, details) + hs := historyStore.Get(heartbeatName) + hs.Add(history.Beat, msg, details) if h.Enabled != nil && !*h.Enabled { http.Error(w, fmt.Sprintf("Heartbeat '%s' not enabled", heartbeatName), http.StatusServiceUnavailable) return } - ns := config.App.NotificationStore - h.StartInterval(ctx, logger, ns, hs) + h.StartInterval(ctx, logger, notificationStore, hs) w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte("ok")); err != nil { diff --git a/pkg/handlers/ping_test.go b/pkg/handlers/ping_test.go index 638196d..adcf2cb 100644 --- a/pkg/handlers/ping_test.go +++ b/pkg/handlers/ping_test.go @@ -1,7 +1,6 @@ package handlers import ( - "heartbeats/pkg/config" "heartbeats/pkg/heartbeat" "heartbeats/pkg/history" "heartbeats/pkg/logger" @@ -17,9 +16,9 @@ import ( func TestPingHandler(t *testing.T) { log := logger.NewLogger(true) - config.App.HeartbeatStore = heartbeat.NewStore() - config.App.NotificationStore = notify.NewStore() - config.HistoryStore = history.NewStore() + heartbeatStore := heartbeat.NewStore() + notificationStore := notify.NewStore() + historyStore := history.NewStore() h := &heartbeat.Heartbeat{ Name: "test", @@ -31,20 +30,20 @@ func TestPingHandler(t *testing.T) { *h.Interval.Interval = time.Minute *h.Grace.Interval = time.Minute - err := config.App.HeartbeatStore.Add("test", h) + err := heartbeatStore.Add("test", h) assert.NoError(t, err) hist, err := history.NewHistory(10, 2) assert.NoError(t, err) - err = config.HistoryStore.Add("test", hist) + err = historyStore.Add("test", hist) assert.NoError(t, err) ns := notify.NewStore() - config.App.NotificationStore = ns + notificationStore = ns mux := http.NewServeMux() - mux.Handle("GET /ping/{id}", Ping(log)) + mux.Handle("GET /ping/{id}", Ping(log, heartbeatStore, notificationStore, historyStore)) t.Run("Heartbeat found and enabled", func(t *testing.T) { req := httptest.NewRequest("GET", "/ping/test", nil) diff --git a/pkg/heartbeat/enums.go b/pkg/heartbeat/enums.go index 87f0f51..5bdce20 100644 --- a/pkg/heartbeat/enums.go +++ b/pkg/heartbeat/enums.go @@ -1,34 +1,49 @@ package heartbeat +// Event represents different types of heartbeat events. type Event int16 const ( + // EventBeat indicates a regular heartbeat event. EventBeat Event = iota + // EventInterval indicates an interval event. EventInterval + // EventGrace indicates a grace period event. EventGrace + // EventExpired indicates an expired event. EventExpired + // EventSend indicates a send event. EventSend ) +// String returns the string representation of the Event. func (e Event) String() string { return [...]string{"BEAT", "INTERVAL", "GRACE", "EXPIRED", "SEND"}[e] } +// Status represents the different statuses of a heartbeat. type Status int16 const ( + // StatusNever indicates the heartbeat has never been active. StatusNever Status = iota + // StatusOK indicates the heartbeat is functioning correctly. StatusOK + // StatusGrace indicates the heartbeat is in a grace period. StatusGrace + // StatusNOK indicates the heartbeat is not functioning correctly. StatusNOK + // StatusUnknown indicates the heartbeat status is unknown. StatusUnknown ) +// String returns the string representation of the Status. func (s Status) String() string { return [...]string{"never", "ok", "grace", "nok", "unknown"}[s] } -// Implement the TextMarshaler interface +// MarshalText implements the encoding.TextMarshaler interface for Status, +// returning the string representation as a byte slice. func (s Status) MarshalText() ([]byte, error) { return []byte(s.String()), nil } diff --git a/pkg/heartbeat/heartbeat.go b/pkg/heartbeat/heartbeat.go index c1f657a..dd03c12 100644 --- a/pkg/heartbeat/heartbeat.go +++ b/pkg/heartbeat/heartbeat.go @@ -179,5 +179,6 @@ func (h *Heartbeat) updateStatus(ctx context.Context, log logger.Logger, newStat func (h *Heartbeat) log(logger logger.Logger, level logger.Level, hi *history.History, eventType Event, msg string) { logMsg := fmt.Sprintf("%s %s", h.Name, msg) logger.Write(level, logMsg) - hi.AddEntry(history.Event(eventType), msg, nil) + + hi.Add(history.Event(eventType), msg, nil) } diff --git a/pkg/history/enums.go b/pkg/history/enums.go index 4205375..097f477 100644 --- a/pkg/history/enums.go +++ b/pkg/history/enums.go @@ -1,15 +1,22 @@ package history +// Event represents different types of events in the history. type Event int16 const ( + // Beat indicates a regular heartbeat event. Beat Event = iota + // Interval indicates an interval event. Interval + // Grace indicates a grace period event. Grace + // Expired indicates an expired event. Expired + // Send indicates a send event. Send ) +// String returns the string representation of the Event. func (e Event) String() string { return [...]string{"BEAT", "INTERVAL", "GRACE", "EXPIRED", "SEND"}[e] } diff --git a/pkg/history/history.go b/pkg/history/history.go index ea4d3cc..6a3e716 100644 --- a/pkg/history/history.go +++ b/pkg/history/history.go @@ -6,6 +6,62 @@ import ( "time" ) +type Config struct { + MaxSize int + Reduce int +} + +// Store manages histories for multiple heartbeats. +type Store struct { + mu sync.RWMutex + histories map[string]*History +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (s *Store) MarshalYAML() (interface{}, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.histories, nil +} + +// NewStore creates a new HistoryStore. +func NewStore() *Store { + return &Store{ + histories: make(map[string]*History), + } +} + +// Get retrieves the history for a given heartbeat. +func (s *Store) Get(name string) *History { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.histories[name] +} + +// Add adds a history entry for a given heartbeat. +func (s *Store) Add(name string, history *History) error { + s.mu.Lock() + defer s.mu.Unlock() + + if exists := s.histories[name]; exists != nil { + return fmt.Errorf("history '%s' already exists", name) + } + + s.histories[name] = history + + return nil +} + +// Delete removes the history for a given heartbeat. +func (s *Store) Delete(name string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.histories, name) +} + // HistoryEntry represents a single entry in the history log. type HistoryEntry struct { Time time.Time @@ -35,8 +91,8 @@ func NewHistory(maxSize, reduceRatio int) (*History, error) { }, nil } -// AddEntry adds a new entry to the history. -func (h *History) AddEntry(event Event, message string, details map[string]string) { +// Add adds a new entry to the history. +func (h *History) Add(event Event, message string, details map[string]string) { h.mu.Lock() defer h.mu.Unlock() @@ -70,54 +126,3 @@ func (h *History) GetAllEntries() []HistoryEntry { return h.entries } - -// Store manages histories for multiple heartbeats. -type Store struct { - mu sync.RWMutex - histories map[string]*History -} - -// MarshalYAML implements the yaml.Marshaler interface. -func (s *Store) MarshalYAML() (interface{}, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.histories, nil -} - -// NewStore creates a new HistoryStore. -func NewStore() *Store { - return &Store{ - histories: make(map[string]*History), - } -} - -// Get retrieves the history for a given heartbeat. -func (s *Store) Get(name string) *History { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.histories[name] -} - -// Add adds a history entry for a given heartbeat. -func (s *Store) Add(name string, history *History) error { - s.mu.Lock() - defer s.mu.Unlock() - - if exists := s.histories[name]; exists != nil { - return fmt.Errorf("history '%s' already exists", name) - } - - s.histories[name] = history - - return nil -} - -// Delete removes the history for a given heartbeat. -func (s *Store) Delete(name string) { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.histories, name) -} diff --git a/pkg/history/history_test.go b/pkg/history/history_test.go index b5fe7ab..34cbadc 100644 --- a/pkg/history/history_test.go +++ b/pkg/history/history_test.go @@ -12,21 +12,21 @@ func TestHistory_NewHistory(t *testing.T) { assert.Error(t, err) } -func TestHistory_AddEntry(t *testing.T) { +func TestHistory_Add(t *testing.T) { t.Run("Add Entries", func(t *testing.T) { h, err := NewHistory(5, 20) assert.NoError(t, err) - h.AddEntry(Beat, "Beat message", nil) + h.Add(Beat, "Beat message", nil) assert.Equal(t, 1, len(h.GetAllEntries())) - h.AddEntry(Interval, "Interval message", nil) - h.AddEntry(Grace, "Grace message", nil) - h.AddEntry(Expired, "Expired message", nil) - h.AddEntry(Send, "Send message", nil) + h.Add(Interval, "Interval message", nil) + h.Add(Grace, "Grace message", nil) + h.Add(Expired, "Expired message", nil) + h.Add(Send, "Send message", nil) assert.Equal(t, 5, len(h.GetAllEntries())) - h.AddEntry(Beat, "New Beat message", nil) + h.Add(Beat, "New Beat message", nil) assert.Equal(t, 4, len(h.GetAllEntries())) // Reduced by 20% (5 * 0.8 = 4) }) @@ -34,16 +34,16 @@ func TestHistory_AddEntry(t *testing.T) { h, err := NewHistory(5, 25) assert.NoError(t, err) - h.AddEntry(Beat, "Beat message", nil) + h.Add(Beat, "Beat message", nil) assert.Equal(t, 1, len(h.GetAllEntries())) - h.AddEntry(Interval, "Interval message", nil) - h.AddEntry(Grace, "Grace message", nil) - h.AddEntry(Expired, "Expired message", nil) - h.AddEntry(Send, "Send message", nil) + h.Add(Interval, "Interval message", nil) + h.Add(Grace, "Grace message", nil) + h.Add(Expired, "Expired message", nil) + h.Add(Send, "Send message", nil) assert.Equal(t, 5, len(h.GetAllEntries())) - h.AddEntry(Beat, "New Beat message", nil) + h.Add(Beat, "New Beat message", nil) assert.Equal(t, 4, len(h.GetAllEntries())) // Reduced by 25% (5 * 0.75 ~= 4) }) } @@ -53,8 +53,8 @@ func TestHistory_GetAllEntries(t *testing.T) { assert.NoError(t, err) t.Run("AddEntries", func(t *testing.T) { - h.AddEntry(Beat, "Beat message", nil) - h.AddEntry(Interval, "Interval message", nil) + h.Add(Beat, "Beat message", nil) + h.Add(Interval, "Interval message", nil) }) t.Run("VerifyEntries", func(t *testing.T) { diff --git a/pkg/logger/logrus_test.go b/pkg/logger/logrus_test.go new file mode 100644 index 0000000..37a6010 --- /dev/null +++ b/pkg/logger/logrus_test.go @@ -0,0 +1,111 @@ +package logger + +import ( + "bytes" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestLogrusLogger(t *testing.T) { + var buf bytes.Buffer + + t.Run("NewLogger sets correct log level", func(t *testing.T) { + log := NewLogger(true) + assert.Equal(t, logrus.DebugLevel, log.logger.Level, "Expected log level to be Debug") + + log = NewLogger(false) + assert.Equal(t, logrus.InfoLevel, log.logger.Level, "Expected log level to be Info") + }) + + t.Run("SetOutput redirects log output", func(t *testing.T) { + log := NewLogger(false) + log.SetOutput(&buf) + + log.Info("Test log") + assert.Contains(t, buf.String(), "Test log", "Expected buffer to contain log message") + }) + + t.Run("Debug logs at Debug level", func(t *testing.T) { + buf.Reset() + log := NewLogger(true) + log.SetOutput(&buf) + + log.Debug("Debug message") + assert.Contains(t, buf.String(), "DEBUG", "Expected buffer to contain 'DEBUG'") + assert.Contains(t, buf.String(), "Debug message", "Expected buffer to contain 'Debug message'") + }) + + t.Run("Info logs at Info level", func(t *testing.T) { + buf.Reset() + log := NewLogger(false) + log.SetOutput(&buf) + + log.Info("Info message") + assert.Contains(t, buf.String(), "INFO", "Expected buffer to contain 'INFO'") + assert.Contains(t, buf.String(), "Info message", "Expected buffer to contain 'Info message'") + }) + + t.Run("Warn logs at Warn level", func(t *testing.T) { + buf.Reset() + log := NewLogger(false) + log.SetOutput(&buf) + + log.Warn("Warn message") + assert.Contains(t, buf.String(), "WARN", "Expected buffer to contain 'WARN'") + assert.Contains(t, buf.String(), "Warn message", "Expected buffer to contain 'Warn message'") + }) + + t.Run("Error logs at Error level", func(t *testing.T) { + buf.Reset() + log := NewLogger(false) + log.SetOutput(&buf) + + log.Error("Error message") + assert.Contains(t, buf.String(), "ERROR", "Expected buffer to contain 'ERROR'") + assert.Contains(t, buf.String(), "Error message", "Expected buffer to contain 'Error message'") + }) + + t.Run("Write logs message at specified level", func(t *testing.T) { + buf.Reset() + log := NewLogger(true) + log.SetOutput(&buf) + + log.Write(DebugLevel, "Write Debug message") + assert.Contains(t, buf.String(), "Write Debug message", "Expected buffer to contain 'Write Debug message'") + + buf.Reset() + log.Write(InfoLevel, "Write Info message") + assert.Contains(t, buf.String(), "Write Info message", "Expected buffer to contain 'Write Info message'") + + buf.Reset() + log.Write(WarnLevel, "Write Warn message") + assert.Contains(t, buf.String(), "Write Warn message", "Expected buffer to contain 'Write Warn message'") + + buf.Reset() + log.Write(ErrorLevel, "Write Error message") + assert.Contains(t, buf.String(), "Write Error message", "Expected buffer to contain 'Write Error message'") + }) + + t.Run("Writef logs formatted message at specified level", func(t *testing.T) { + buf.Reset() + log := NewLogger(true) + log.SetOutput(&buf) + + log.Writef(DebugLevel, "Writef %s", "Debug message") + assert.Contains(t, buf.String(), "Writef Debug message", "Expected buffer to contain 'Writef Debug message'") + + buf.Reset() + log.Writef(InfoLevel, "Writef %s", "Info message") + assert.Contains(t, buf.String(), "Writef Info message", "Expected buffer to contain 'Writef Info message'") + + buf.Reset() + log.Writef(WarnLevel, "Writef %s", "Warn message") + assert.Contains(t, buf.String(), "Writef Warn message", "Expected buffer to contain 'Writef Warn message'") + + buf.Reset() + log.Writef(ErrorLevel, "Writef %s", "Error message") + assert.Contains(t, buf.String(), "Writef Error message", "Expected buffer to contain 'Writef Error message'") + }) +} diff --git a/pkg/notify/notifier/email.go b/pkg/notify/notifier/email.go index d266aa6..1f7deea 100644 --- a/pkg/notify/notifier/email.go +++ b/pkg/notify/notifier/email.go @@ -5,6 +5,7 @@ import ( "fmt" "heartbeats/pkg/notify/resolver" "heartbeats/pkg/notify/services/email" + "heartbeats/pkg/notify/utils" "time" ) @@ -31,7 +32,7 @@ type EmailNotifier struct { // // Returns: // - error: An error if sending the notification fails. -func (e EmailNotifier) Send(ctx context.Context, data interface{}, isResolved bool, formatter Formatter) error { +func (e *EmailNotifier) Send(ctx context.Context, data interface{}, isResolved bool, formatter Formatter) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -62,11 +63,30 @@ func (e EmailNotifier) Send(ctx context.Context, data interface{}, isResolved bo return nil } +// ValidateTemplate validates the notification templates against the provided data. +// +// Parameters: +// - data: The data to be injected into the templates for validation. +// +// Returns: +// - error: An error if the notification template cannot be validated. +func (e *EmailNotifier) ValidateTemplate(data interface{}) error { + if _, err := utils.FormatTemplate("title", e.Config.Email.Subject, data); err != nil { + return fmt.Errorf("cannot validate email subject template. %s", err) + } + + if _, err := utils.FormatTemplate("text", e.Config.Email.Body, data); err != nil { + return fmt.Errorf("cannot validate email body template. %s", err) + } + + return nil +} + // CheckResolveVariables checks if the configuration fields are resolvable. // // Returns: // - error: An error if the configuration fields are not resolvable. -func (e EmailNotifier) CheckResolveVariables() error { +func (e *EmailNotifier) CheckResolveVariables() error { if _, err := resolveSMTPConfig(e.Config.SMTP); err != nil { return fmt.Errorf("cannot resolve SMTP config. %w", err) } @@ -153,6 +173,6 @@ func resolveEmailConfig(config email.Email) (email.Email, error) { } // String returns the type of the notifier. -func (e EmailNotifier) String() string { +func (e *EmailNotifier) String() string { return EmailType } diff --git a/pkg/notify/notifier/msteams.go b/pkg/notify/notifier/msteams.go index b695aab..008984e 100644 --- a/pkg/notify/notifier/msteams.go +++ b/pkg/notify/notifier/msteams.go @@ -5,6 +5,7 @@ import ( "fmt" "heartbeats/pkg/notify/resolver" "heartbeats/pkg/notify/services/msteams" + "heartbeats/pkg/notify/utils" "time" ) @@ -32,13 +33,13 @@ type MSTeamsNotifier struct { // // Returns: // - error: An error if sending the notification fails. -func (s MSTeamsNotifier) Send(ctx context.Context, data interface{}, isResolved bool, formatter Formatter) error { +func (m *MSTeamsNotifier) Send(ctx context.Context, data interface{}, isResolved bool, formatter Formatter) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() msteamsClient := msteams.New(nil, true) - msteamsConfig, err := resolveMSTeamsConfig(s.Config) + msteamsConfig, err := resolveMSTeamsConfig(m.Config) if err != nil { return fmt.Errorf("cannot resolve MS Teams config. %w", err) } @@ -65,12 +66,31 @@ func (s MSTeamsNotifier) Send(ctx context.Context, data interface{}, isResolved return nil } +// ValidateTemplate validates the notification templates against the provided data. +// +// Parameters: +// - data: The data to be injected into the templates for validation. +// +// Returns: +// - error: An error if the notification template cannot be validated. +func (m *MSTeamsNotifier) ValidateTemplate(data interface{}) error { + if _, err := utils.FormatTemplate("title", m.Config.Title, data); err != nil { + return fmt.Errorf("cannot validate MS Teams title template. %s", err) + } + + if _, err := utils.FormatTemplate("text", m.Config.Text, data); err != nil { + return fmt.Errorf("cannot validate MS Teams text template. %s", err) + } + + return nil +} + // CheckResolveVariables checks if the configuration fields are resolvable. // // Returns: // - error: An error if the configuration fields are not resolvable. -func (e MSTeamsNotifier) CheckResolveVariables() error { - if _, err := resolveMSTeamsConfig(e.Config); err != nil { +func (m *MSTeamsNotifier) CheckResolveVariables() error { + if _, err := resolveMSTeamsConfig(m.Config); err != nil { return err } @@ -109,6 +129,6 @@ func resolveMSTeamsConfig(config MSTeamsConfig) (MSTeamsConfig, error) { } // String returns the type of the notifier. -func (m MSTeamsNotifier) String() string { +func (m *MSTeamsNotifier) String() string { return MSTeamsType } diff --git a/pkg/notify/notifier/notifier.go b/pkg/notify/notifier/notifier.go index 1e967b7..ab28e78 100644 --- a/pkg/notify/notifier/notifier.go +++ b/pkg/notify/notifier/notifier.go @@ -21,9 +21,18 @@ type Notifier interface { // - error: An error if sending the notification fails. Send(ctx context.Context, data interface{}, isResolved bool, formatter Formatter) error - // CheckResolveVariables checks if the notifier configuration is valid. + // CheckResolveVariables checks if the configuration fields are resolvable. // // Returns: - // - error: An error if the configuration is invalid. + // - error: An error if any of the configuration fields cannot be resolved. CheckResolveVariables() error + + // ValidateTemplate validates the notification templates against the provided data. + // + // Parameters: + // - data: The data to be injected into the templates for validation. + // + // Returns: + // - error: An error if the notification template cannot be validated. + ValidateTemplate(data interface{}) error } diff --git a/pkg/notify/notifier/slack.go b/pkg/notify/notifier/slack.go index ac83caf..eaf283b 100644 --- a/pkg/notify/notifier/slack.go +++ b/pkg/notify/notifier/slack.go @@ -35,7 +35,7 @@ type SlackNotifier struct { // // Returns: // - error: An error if sending the notification fails. -func (s SlackNotifier) Send(ctx context.Context, data interface{}, isResolved bool, formatter Formatter) error { +func (s *SlackNotifier) Send(ctx context.Context, data interface{}, isResolved bool, formatter Formatter) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -84,11 +84,30 @@ func (s SlackNotifier) Send(ctx context.Context, data interface{}, isResolved bo return nil } +// ValidateTemplate validates the notification templates against the provided data. +// +// Parameters: +// - data: The data to be injected into the templates for validation. +// +// Returns: +// - error: An error if the notification template cannot be validated. +func (s *SlackNotifier) ValidateTemplate(data interface{}) error { + if _, err := utils.FormatTemplate("title", s.Config.Title, data); err != nil { + return fmt.Errorf("cannot validate Slack title template. %s", err) + } + + if _, err := utils.FormatTemplate("text", s.Config.Text, data); err != nil { + return fmt.Errorf("cannot validate Slack text template. %s", err) + } + + return nil +} + // CheckResolveVariables checks if the configuration fields are resolvable. // // Returns: // - error: An error if any of the configuration fields cannot be resolved. -func (e SlackNotifier) CheckResolveVariables() error { +func (e *SlackNotifier) CheckResolveVariables() error { if _, err := resolveSlackConfig(e.Config); err != nil { return err } @@ -103,7 +122,6 @@ func (e SlackNotifier) CheckResolveVariables() error { // // Returns: // - SlackConfig: The resolved SlackConfig. -// - error: An error if any of the configuration values cannot be resolved. func resolveSlackConfig(config SlackConfig) (SlackConfig, error) { token, err := resolver.ResolveVariable(config.Token) if err != nil { @@ -140,7 +158,7 @@ func resolveSlackConfig(config SlackConfig) (SlackConfig, error) { } // String returns the type of the notifier. -func (s SlackNotifier) String() string { +func (s *SlackNotifier) String() string { return SlackType } diff --git a/pkg/notify/notify.go b/pkg/notify/notify.go index 63d5543..294a173 100644 --- a/pkg/notify/notify.go +++ b/pkg/notify/notify.go @@ -71,6 +71,17 @@ func (n *Notification) CheckResolveVariables() error { return n.Notifier.CheckResolveVariables() } +// ValidateTemplate validates the notification templates against the provided data. +// +// Parameters: +// - data: The data to be injected into the templates for validation. +// +// Returns: +// - error: An error if the notification template cannot be validated. +func (n *Notification) ValidateTemplate(data interface{}) error { + return n.Notifier.ValidateTemplate(data) +} + // GetAll retrieves a copy of all notifications in the store. // // Returns: @@ -154,6 +165,7 @@ func (s *Store) Update(name string, notification *Notification) error { notification.Notifier = instance s.notifications[name] = notification + return nil } diff --git a/pkg/server/routes.go b/pkg/server/routes.go index b1e59f4..391e024 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -2,13 +2,24 @@ package server import ( "heartbeats/pkg/handlers" + "heartbeats/pkg/heartbeat" + "heartbeats/pkg/history" "heartbeats/pkg/logger" + "heartbeats/pkg/notify" "io/fs" "net/http" ) // newRouter creates a new Server mux and appends Handlers -func newRouter(logger logger.Logger, staticFS fs.FS) http.Handler { +func newRouter( + logger logger.Logger, + staticFS fs.FS, + version string, + siteRoot string, + heartbeatStore *heartbeat.Store, + notificationStore *notify.Store, + historyStore *history.Store, +) http.Handler { mux := http.NewServeMux() // Handler for embedded static files @@ -16,10 +27,10 @@ func newRouter(logger logger.Logger, staticFS fs.FS) http.Handler { fileServer := http.FileServer(http.FS(staticContent)) mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) - mux.Handle("GET /", handlers.Heartbeats(logger, staticFS)) - mux.Handle("GET /ping/{id}", handlers.Ping(logger)) - mux.Handle("POST /ping/{id}", handlers.Ping(logger)) - mux.Handle("GET /history/{id}", handlers.History(logger, staticFS)) + mux.Handle("GET /", handlers.Heartbeats(logger, staticFS, version, siteRoot, heartbeatStore, notificationStore)) + mux.Handle("GET /ping/{id}", handlers.Ping(logger, heartbeatStore, notificationStore, historyStore)) + mux.Handle("POST /ping/{id}", handlers.Ping(logger, heartbeatStore, notificationStore, historyStore)) + mux.Handle("GET /history/{id}", handlers.History(logger, staticFS, version, heartbeatStore, historyStore)) mux.Handle("GET /healthz", handlers.Healthz(logger)) mux.Handle("POST /healthz", handlers.Healthz(logger)) mux.Handle("GET /metrics", handlers.Metrics(logger)) diff --git a/pkg/server/routes_test.go b/pkg/server/routes_test.go index 4ec636e..e983364 100644 --- a/pkg/server/routes_test.go +++ b/pkg/server/routes_test.go @@ -1,7 +1,6 @@ package server import ( - "heartbeats/pkg/config" "heartbeats/pkg/heartbeat" "heartbeats/pkg/history" "heartbeats/pkg/logger" @@ -60,10 +59,15 @@ func setupAferoFSForRoutes() afero.Fs { } func TestNewRouter(t *testing.T) { + version := "1.0.0" + log := logger.NewLogger(true) - config.App.HeartbeatStore = heartbeat.NewStore() - config.App.NotificationStore = notify.NewStore() - config.HistoryStore = history.NewStore() + + heartbeatStore := heartbeat.NewStore() + notificationStore := notify.NewStore() + historyStore := history.NewStore() + + siteRoot := "localhost:8080" h := &heartbeat.Heartbeat{ Name: "test", @@ -75,19 +79,19 @@ func TestNewRouter(t *testing.T) { *h.Interval.Interval = time.Minute *h.Grace.Interval = time.Minute - err := config.App.HeartbeatStore.Add("test", h) + err := heartbeatStore.Add("test", h) assert.NoError(t, err) hist, err := history.NewHistory(10, 2) assert.NoError(t, err) - err = config.HistoryStore.Add("test", hist) + err = historyStore.Add("test", hist) assert.NoError(t, err) aferoFS := setupAferoFSForRoutes() customFS := aferoToCustomAferoFS(aferoFS) - mux := newRouter(log, customFS) + mux := newRouter(log, customFS, version, siteRoot, heartbeatStore, notificationStore, historyStore) t.Run("GET /", func(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) diff --git a/pkg/server/server.go b/pkg/server/server.go index ed3593e..c99990d 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -3,18 +3,43 @@ package server import ( "context" "embed" + "heartbeats/pkg/heartbeat" + "heartbeats/pkg/history" "heartbeats/pkg/logger" + "heartbeats/pkg/notify" "net/http" "sync" "time" ) +type Config struct { + ListenAddress string + SiteRoot string +} + // Run starts the HTTP server and handles shutdown on context cancellation. -func Run(ctx context.Context, listenAddress string, templates embed.FS, logger logger.Logger) error { - router := newRouter(logger, templates) +func Run( + ctx context.Context, + logger logger.Logger, + version string, + config Config, + templates embed.FS, + heartbeatStore *heartbeat.Store, + notificationStore *notify.Store, + historyStore *history.Store, +) error { + router := newRouter( + logger, + templates, + version, + config.SiteRoot, + heartbeatStore, + notificationStore, + historyStore, + ) server := &http.Server{ - Addr: listenAddress, + Addr: config.ListenAddress, Handler: router, ReadTimeout: 15 * time.Second, WriteTimeout: 10 * time.Second, @@ -23,7 +48,7 @@ func Run(ctx context.Context, listenAddress string, templates embed.FS, logger l // Start server in a goroutine go func() { - logger.Infof("Listening on %s", listenAddress) + logger.Infof("Listening on %s", config.ListenAddress) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Errorf("Error listening and serving. %s", err) } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 8dffe85..5a6c25d 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -3,7 +3,10 @@ package server import ( "context" "embed" + "heartbeats/pkg/heartbeat" + "heartbeats/pkg/history" "heartbeats/pkg/logger" + "heartbeats/pkg/notify" "testing" "time" @@ -11,7 +14,19 @@ import ( ) func TestRun(t *testing.T) { + version := "1.0.0" + log := logger.NewLogger(true) + + heartbeatStore := heartbeat.NewStore() + notificationStore := notify.NewStore() + historyStore := history.NewStore() + + config := Config{ + ListenAddress: "localhost:8080", + SiteRoot: "http://localhost:8080", + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -20,7 +35,16 @@ func TestRun(t *testing.T) { cancel() }() - var staticFS embed.FS - err := Run(ctx, "localhost:8080", staticFS, log) + var templates embed.FS + err := Run( + ctx, + log, + version, + config, + templates, + heartbeatStore, + notificationStore, + historyStore, + ) assert.NoError(t, err) }