From 47e592b83b67bb21507c64cd20cab95221c7f6f0 Mon Sep 17 00:00:00 2001 From: igophper Date: Sun, 26 Jan 2025 11:30:33 +0800 Subject: [PATCH] feat: refactor main logic into cmd package and add cobra cli support --- cmd/root.go | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + go.sum | 8 ++ main.go | 194 +------------------------------------------- 4 files changed, 243 insertions(+), 192 deletions(-) create mode 100644 cmd/root.go diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000000..d132792b0e --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/controller" + "github.com/TwiN/gatus/v5/storage/store" + "github.com/TwiN/gatus/v5/watchdog" + "github.com/TwiN/logr" + "github.com/spf13/cobra" + "os" + "os/signal" + "strconv" + "syscall" + "time" +) + +var testConfig bool +var version = "dev" + +func Execute() { + _ = rootCmd.Execute() +} + +func init() { + rootCmd.Flags().BoolVarP(&testConfig, "test-config", "t", false, `test whether the configuration file is correct`) +} + +var rootCmd = &cobra.Command{ + Use: "gatus", + Version: version, + Short: "Gatus is a simple service health checker", + Long: "Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS queries as well as evaluate the result of said queries by using a list of conditions on values like the status code, the response time, the certificate expiration, the body and many others. The icing on top is that each of these health checks can be paired with alerting via Slack, Teams, PagerDuty, Discord, Twilio and many more.", + Run: func(cmd *cobra.Command, args []string) { + if testConfig { + _, err := loadConfiguration() + if err != nil { + panic(err) + } + logr.Info("Configuration file is valid") + return + } + run() + }, +} + +const ( + GatusConfigPathEnvVar = "GATUS_CONFIG_PATH" + GatusConfigFileEnvVar = "GATUS_CONFIG_FILE" // Deprecated in favor of GatusConfigPathEnvVar + GatusLogLevelEnvVar = "GATUS_LOG_LEVEL" +) + +func run() { + if delayInSeconds, _ := strconv.Atoi(os.Getenv("GATUS_DELAY_START_SECONDS")); delayInSeconds > 0 { + logr.Infof("Delaying start by %d seconds", delayInSeconds) + time.Sleep(time.Duration(delayInSeconds) * time.Second) + } + + configureLogging() + + cfg, err := loadConfiguration() + if err != nil { + panic(err) + } + + initializeStorage(cfg) + start(cfg) + // Wait for termination signal + signalChannel := make(chan os.Signal, 1) + done := make(chan bool, 1) + signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM) + go func() { + <-signalChannel + logr.Info("Received termination signal, attempting to gracefully shut down") + stop(cfg) + save() + done <- true + }() + <-done + logr.Info("Shutting down") +} + +func start(cfg *config.Config) { + go controller.Handle(cfg) + watchdog.Monitor(cfg) + go listenToConfigurationFileChanges(cfg) +} + +func stop(cfg *config.Config) { + watchdog.Shutdown(cfg) + controller.Shutdown() +} + +func save() { + if err := store.Get().Save(); err != nil { + logr.Errorf("Failed to save storage provider: %s", err.Error()) + } +} + +func configureLogging() { + logLevelAsString := os.Getenv(GatusLogLevelEnvVar) + if logLevel, err := logr.LevelFromString(logLevelAsString); err != nil { + logr.SetThreshold(logr.LevelInfo) + if len(logLevelAsString) == 0 { + logr.Infof("[main.configureLogging] Defaulting log level to %s", logr.LevelInfo) + } else { + logr.Warnf("[main.configureLogging] Invalid log level '%s', defaulting to %s", logLevelAsString, logr.LevelInfo) + } + } else { + logr.SetThreshold(logLevel) + logr.Infof("[main.configureLogging] Log Level is set to %s", logr.GetThreshold()) + } +} + +func loadConfiguration() (*config.Config, error) { + configPath := os.Getenv(GatusConfigPathEnvVar) + // Backwards compatibility + if len(configPath) == 0 { + if configPath = os.Getenv(GatusConfigFileEnvVar); len(configPath) > 0 { + logr.Warnf("WARNING: %s is deprecated. Please use %s instead.", GatusConfigFileEnvVar, GatusConfigPathEnvVar) + } + } + + return config.LoadConfiguration(configPath) +} + +// initializeStorage initializes the storage provider +// +// Q: "TwiN, why are you putting this here? Wouldn't it make more sense to have this in the config?!" +// A: Yes. Yes it would make more sense to have it in the config package. But I don't want to import +// the massive SQL dependencies just because I want to import the config, so here we are. +func initializeStorage(cfg *config.Config) { + err := store.Initialize(cfg.Storage) + if err != nil { + panic(err) + } + // Remove all EndpointStatus that represent endpoints which no longer exist in the configuration + var keys []string + for _, ep := range cfg.Endpoints { + keys = append(keys, ep.Key()) + } + for _, ee := range cfg.ExternalEndpoints { + keys = append(keys, ee.Key()) + } + numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys) + if numberOfEndpointStatusesDeleted > 0 { + logr.Infof("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted) + } + // Clean up the triggered alerts from the storage provider and load valid triggered endpoint alerts + numberOfPersistedTriggeredAlertsLoaded := 0 + for _, ep := range cfg.Endpoints { + var checksums []string + for _, alert := range ep.Alerts { + if alert.IsEnabled() { + checksums = append(checksums, alert.Checksum()) + } + } + numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums) + if numberOfTriggeredAlertsDeleted > 0 { + logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key()) + } + for _, alert := range ep.Alerts { + exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert) + if err != nil { + logr.Errorf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ep.Key(), err.Error()) + continue + } + if exists { + alert.Triggered, alert.ResolveKey = true, resolveKey + ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold + numberOfPersistedTriggeredAlertsLoaded++ + } + } + } + for _, ee := range cfg.ExternalEndpoints { + var checksums []string + for _, alert := range ee.Alerts { + if alert.IsEnabled() { + checksums = append(checksums, alert.Checksum()) + } + } + convertedEndpoint := ee.ToEndpoint() + numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(convertedEndpoint, checksums) + if numberOfTriggeredAlertsDeleted > 0 { + logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ee.Key()) + } + for _, alert := range ee.Alerts { + exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(convertedEndpoint, alert) + if err != nil { + logr.Errorf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ee.Key(), err.Error()) + continue + } + if exists { + alert.Triggered, alert.ResolveKey = true, resolveKey + ee.NumberOfSuccessesInARow, ee.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold + numberOfPersistedTriggeredAlertsLoaded++ + } + } + } + if numberOfPersistedTriggeredAlertsLoaded > 0 { + logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded) + } +} + +func listenToConfigurationFileChanges(cfg *config.Config) { + for { + time.Sleep(30 * time.Second) + if cfg.HasLoadedConfigurationBeenModified() { + logr.Info("[main.listenToConfigurationFileChanges] Configuration file has been modified") + stop(cfg) + time.Sleep(time.Second) // Wait a bit to make sure everything is done. + save() + updatedConfig, err := loadConfiguration() + if err != nil { + if cfg.SkipInvalidConfigUpdate { + logr.Errorf("[main.listenToConfigurationFileChanges] Failed to load new configuration: %s", err.Error()) + logr.Error("[main.listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.") + // Update the last file modification time to avoid trying to process the same invalid configuration again + cfg.UpdateLastFileModTime() + continue + } else { + panic(err) + } + } + store.Get().Close() + initializeStorage(updatedConfig) + start(updatedConfig) + return + } + } +} diff --git a/go.mod b/go.mod index e887022bff..d0555955f7 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/miekg/dns v1.1.62 github.com/prometheus-community/pro-bing v0.5.0 github.com/prometheus/client_golang v1.20.5 + github.com/spf13/cobra v1.8.1 github.com/valyala/fasthttp v1.58.0 github.com/wcharczuk/go-chart/v2 v2.1.2 golang.org/x/crypto v0.31.0 @@ -52,6 +53,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/kr/text v0.2.0 // indirect @@ -67,6 +69,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect diff --git a/go.sum b/go.sum index 98b40a8400..2a99b35737 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -72,6 +73,8 @@ github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mO github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo= github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -120,6 +123,11 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= diff --git a/main.go b/main.go index bbb6e0c100..a34ff32e16 100644 --- a/main.go +++ b/main.go @@ -1,197 +1,7 @@ package main -import ( - "os" - "os/signal" - "strconv" - "syscall" - "time" - - "github.com/TwiN/gatus/v5/config" - "github.com/TwiN/gatus/v5/controller" - "github.com/TwiN/gatus/v5/storage/store" - "github.com/TwiN/gatus/v5/watchdog" - "github.com/TwiN/logr" -) - -const ( - GatusConfigPathEnvVar = "GATUS_CONFIG_PATH" - GatusConfigFileEnvVar = "GATUS_CONFIG_FILE" // Deprecated in favor of GatusConfigPathEnvVar - GatusLogLevelEnvVar = "GATUS_LOG_LEVEL" -) +import "github.com/TwiN/gatus/v5/cmd" func main() { - if delayInSeconds, _ := strconv.Atoi(os.Getenv("GATUS_DELAY_START_SECONDS")); delayInSeconds > 0 { - logr.Infof("Delaying start by %d seconds", delayInSeconds) - time.Sleep(time.Duration(delayInSeconds) * time.Second) - } - configureLogging() - cfg, err := loadConfiguration() - if err != nil { - panic(err) - } - initializeStorage(cfg) - start(cfg) - // Wait for termination signal - signalChannel := make(chan os.Signal, 1) - done := make(chan bool, 1) - signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM) - go func() { - <-signalChannel - logr.Info("Received termination signal, attempting to gracefully shut down") - stop(cfg) - save() - done <- true - }() - <-done - logr.Info("Shutting down") -} - -func start(cfg *config.Config) { - go controller.Handle(cfg) - watchdog.Monitor(cfg) - go listenToConfigurationFileChanges(cfg) -} - -func stop(cfg *config.Config) { - watchdog.Shutdown(cfg) - controller.Shutdown() -} - -func save() { - if err := store.Get().Save(); err != nil { - logr.Errorf("Failed to save storage provider: %s", err.Error()) - } -} - -func configureLogging() { - logLevelAsString := os.Getenv(GatusLogLevelEnvVar) - if logLevel, err := logr.LevelFromString(logLevelAsString); err != nil { - logr.SetThreshold(logr.LevelInfo) - if len(logLevelAsString) == 0 { - logr.Infof("[main.configureLogging] Defaulting log level to %s", logr.LevelInfo) - } else { - logr.Warnf("[main.configureLogging] Invalid log level '%s', defaulting to %s", logLevelAsString, logr.LevelInfo) - } - } else { - logr.SetThreshold(logLevel) - logr.Infof("[main.configureLogging] Log Level is set to %s", logr.GetThreshold()) - } -} - -func loadConfiguration() (*config.Config, error) { - configPath := os.Getenv(GatusConfigPathEnvVar) - // Backwards compatibility - if len(configPath) == 0 { - if configPath = os.Getenv(GatusConfigFileEnvVar); len(configPath) > 0 { - logr.Warnf("WARNING: %s is deprecated. Please use %s instead.", GatusConfigFileEnvVar, GatusConfigPathEnvVar) - } - } - return config.LoadConfiguration(configPath) -} - -// initializeStorage initializes the storage provider -// -// Q: "TwiN, why are you putting this here? Wouldn't it make more sense to have this in the config?!" -// A: Yes. Yes it would make more sense to have it in the config package. But I don't want to import -// the massive SQL dependencies just because I want to import the config, so here we are. -func initializeStorage(cfg *config.Config) { - err := store.Initialize(cfg.Storage) - if err != nil { - panic(err) - } - // Remove all EndpointStatus that represent endpoints which no longer exist in the configuration - var keys []string - for _, ep := range cfg.Endpoints { - keys = append(keys, ep.Key()) - } - for _, ee := range cfg.ExternalEndpoints { - keys = append(keys, ee.Key()) - } - numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys) - if numberOfEndpointStatusesDeleted > 0 { - logr.Infof("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted) - } - // Clean up the triggered alerts from the storage provider and load valid triggered endpoint alerts - numberOfPersistedTriggeredAlertsLoaded := 0 - for _, ep := range cfg.Endpoints { - var checksums []string - for _, alert := range ep.Alerts { - if alert.IsEnabled() { - checksums = append(checksums, alert.Checksum()) - } - } - numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums) - if numberOfTriggeredAlertsDeleted > 0 { - logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key()) - } - for _, alert := range ep.Alerts { - exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert) - if err != nil { - logr.Errorf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ep.Key(), err.Error()) - continue - } - if exists { - alert.Triggered, alert.ResolveKey = true, resolveKey - ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold - numberOfPersistedTriggeredAlertsLoaded++ - } - } - } - for _, ee := range cfg.ExternalEndpoints { - var checksums []string - for _, alert := range ee.Alerts { - if alert.IsEnabled() { - checksums = append(checksums, alert.Checksum()) - } - } - convertedEndpoint := ee.ToEndpoint() - numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(convertedEndpoint, checksums) - if numberOfTriggeredAlertsDeleted > 0 { - logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ee.Key()) - } - for _, alert := range ee.Alerts { - exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(convertedEndpoint, alert) - if err != nil { - logr.Errorf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ee.Key(), err.Error()) - continue - } - if exists { - alert.Triggered, alert.ResolveKey = true, resolveKey - ee.NumberOfSuccessesInARow, ee.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold - numberOfPersistedTriggeredAlertsLoaded++ - } - } - } - if numberOfPersistedTriggeredAlertsLoaded > 0 { - logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded) - } -} - -func listenToConfigurationFileChanges(cfg *config.Config) { - for { - time.Sleep(30 * time.Second) - if cfg.HasLoadedConfigurationBeenModified() { - logr.Info("[main.listenToConfigurationFileChanges] Configuration file has been modified") - stop(cfg) - time.Sleep(time.Second) // Wait a bit to make sure everything is done. - save() - updatedConfig, err := loadConfiguration() - if err != nil { - if cfg.SkipInvalidConfigUpdate { - logr.Errorf("[main.listenToConfigurationFileChanges] Failed to load new configuration: %s", err.Error()) - logr.Error("[main.listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.") - // Update the last file modification time to avoid trying to process the same invalid configuration again - cfg.UpdateLastFileModTime() - continue - } else { - panic(err) - } - } - store.Get().Close() - initializeStorage(updatedConfig) - start(updatedConfig) - return - } - } + cmd.Execute() }