diff --git a/cmd/check.go b/cmd/check.go index 42e676b..6b39391 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -22,6 +22,6 @@ var checkBoxCmd = &cobra.Command{ Args: BoxIdValidator, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return checkAndNotify(args, defaultConf) + return checkAndNotify(args) }, } diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..daa25e1 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "os" + "path" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +/** + * config file handling, as it is kinda broken in spf13/viper + * mostly copied from https://github.com/TheThingsNetwork/ttn/blob/f623a6a/ttnctl/util/config.go + */ + +// GetConfigFile returns the location of the configuration file. +// It checks the following (in this order): +// the --config flag +// $XDG_CONFIG_HOME/osem_notify/config.yml (if $XDG_CONFIG_HOME is set) +// $HOME/.osem_notify.yml +func getConfigFile() string { + flag := viper.GetString("config") + + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg != "" { + xdg = path.Join(xdg, "osem_notify", "config.yml") + } + + home := os.Getenv("HOME") + homeyml := "" + homeyaml := "" + + if home != "" { + homeyml = path.Join(home, ".osem_notify.yml") + homeyaml = path.Join(home, ".osem_notify.yaml") + } + + try_files := []string{ + flag, + xdg, + homeyml, + homeyaml, + } + + // find a file that exists, and use that + for _, file := range try_files { + if file != "" { + if _, err := os.Stat(file); err == nil { + return file + } + } + } + + // no file found, set up correct fallback + if os.Getenv("XDG_CONFIG_HOME") != "" { + return xdg + } else { + return homeyml + } +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + theConfig := cfgFile + if cfgFile == "" { + theConfig = getConfigFile() + } + + viper.SetConfigType("yaml") + viper.SetConfigFile(theConfig) + viper.SetEnvPrefix("OSEM_NOTIFY") // keys only work in upper case + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + viper.AutomaticEnv() // WARNING: OSEM_NOTIFIY_CONFIG will not be considered this way. but why should it.. + + // If a config file is found, read it in. + if _, err := os.Stat(theConfig); err == nil { + err := viper.ReadInConfig() + if err != nil { + log.Error("Error when reading config file:", err) + } + } else if cfgFile != "" { + log.Error("Specified config file not found!") + os.Exit(1) + } + + validateConfig() +} + +func validateConfig() { + transport := viper.GetString("defaultHealthchecks.notifications.transport") + if viper.GetBool("notify") && transport == "email" { + if len(viper.GetStringSlice("defaultHealthchecks.notifications.options.recipients")) == 0 { + log.Warn("No recipients set up for transport email") + } + + emailRequired := []string{ + viper.GetString("email.host"), + viper.GetString("email.port"), + viper.GetString("email.user"), + viper.GetString("email.pass"), + viper.GetString("email.from"), + } + for _, conf := range emailRequired { + if conf == "" { + log.Error("Default transport set as email, but missing email config") + os.Exit(1) + } + } + } +} + +func printConfig() { + log.Debug("Using config:") + printKV("config file", viper.ConfigFileUsed()) + for key, val := range viper.AllSettings() { + printKV(key, val) + } +} + +func printKV(key, val interface{}) { + log.Debugf("%20s: %v", key, val) +} diff --git a/cmd/root.go b/cmd/root.go index e744edb..268be71 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,7 +2,6 @@ package cmd import ( "os" - "strings" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -10,38 +9,19 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "osem_notify", - Long: "Run healthchecks and send notifications for boxes on opensensemap.org", + Use: "osem_notify", + Short: "Root command displaying help", + Long: "Run healthchecks and send notifications for boxes on opensensemap.org", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // set up config environment FIXME: cannot open / write file?! - viper.SetConfigType("json") - viper.SetConfigFile(".osem_notify") - viper.AddConfigPath("$HOME") - viper.AddConfigPath(".") - // // If a config file is found, read it in. - // if _, err := os.Stat(path.Join(os.Getenv("HOME"), ".osem_notify.yml")); err == nil { - // err := viper.ReadInConfig() - // if err != nil { - // fmt.Println("Error when reading config file:", err) - // } - // } - viper.SetEnvPrefix("osem_notify") - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) - viper.AutomaticEnv() - // set up logger log.SetOutput(os.Stdout) - switch logLevel { - case "debug": + if viper.GetBool("debug") { log.SetLevel(log.DebugLevel) - case "info": + printConfig() + } else { log.SetLevel(log.InfoLevel) - case "warn": - log.SetLevel(log.WarnLevel) - case "error": - log.SetLevel(log.ErrorLevel) } - switch logFormat { + switch viper.Get("logformat") { case "json": log.SetFormatter(&log.JSONFormatter{}) } @@ -53,19 +33,33 @@ var rootCmd = &cobra.Command{ }, } -var ( - shouldNotify bool - logLevel string - logFormat string -) +// accessed in initConfig(), as it is initialized before config is loaded (sic) +var cfgFile string func init() { - rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "", "info", "log level, can be one of debug, info, warn, error") - rootCmd.PersistentFlags().StringVarP(&logFormat, "log-format", "", "plain", "log format, can be plain or json") + var ( + shouldNotify bool + debug bool + logFormat string + ) + + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "path to config file (default $HOME/.osem_notify.yml)") + rootCmd.PersistentFlags().StringVarP(&logFormat, "logformat", "l", "plain", "log format, can be plain or json") + rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "enable verbose logging") rootCmd.PersistentFlags().BoolVarP(&shouldNotify, "notify", "n", false, "if set, will send out notifications.\nOtherwise results are printed to stdout only") + + viper.BindPFlags(rootCmd.PersistentFlags()) // let flags override config } func Execute() { + // generate documentation + // err := doc.GenMarkdownTree(rootCmd, "./doc") + // if err != nil { + // log.Fatal(err) + // } + if err := rootCmd.Execute(); err != nil { os.Exit(1) } diff --git a/cmd/shared.go b/cmd/shared.go index d2e3724..852ab51 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -5,6 +5,7 @@ import ( "regexp" "github.com/spf13/cobra" + "github.com/spf13/viper" "../core" ) @@ -13,29 +14,6 @@ import ( * shared functionality between watch and check */ -// TODO: actually to be read from arg / file - -var defaultConf = &core.NotifyConfig{ - Notifications: core.TransportConfig{ - Transport: "email", - Options: core.EmailNotifier{ - []string{"test@nroo.de"}, - "notify@nroo.de", - }, - }, - Events: []core.NotifyEvent{ - core.NotifyEvent{ - Type: "measurement_age", - Target: "all", - Threshold: "15m", - }, - core.NotifyEvent{ - Type: "measurement_suspicious", - Target: "all", - }, - }, -} - func isValidBoxId(boxId string) bool { // boxIds are UUIDs r := regexp.MustCompile("^[0-9a-fA-F]{24}$") @@ -54,15 +32,21 @@ func BoxIdValidator(cmd *cobra.Command, args []string) error { return nil } -func checkAndNotify(boxIds []string, defaultNotifyConf *core.NotifyConfig) error { - results, err := core.CheckBoxes(boxIds, defaultConf) +func checkAndNotify(boxIds []string) error { + defaultNotifyConf := &core.NotifyConfig{} + err := viper.UnmarshalKey("defaultHealthchecks", defaultNotifyConf) + if err != nil { + return err + } + + results, err := core.CheckBoxes(boxIds, defaultNotifyConf) if err != nil { return err } results.Log() - if shouldNotify { + if viper.GetBool("notify") { return results.SendNotifications() } return nil diff --git a/cmd/watch.go b/cmd/watch.go index 807eb1b..7e07971 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -32,13 +32,13 @@ var watchBoxesCmd = &cobra.Command{ }, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - err := checkAndNotify(args, defaultConf) + err := checkAndNotify(args) if err != nil { return err } for { <-ticker - err = checkAndNotify(args, defaultConf) + err = checkAndNotify(args) if err != nil { return err } diff --git a/core/Box.go b/core/Box.go index c8d96fe..e6079d6 100644 --- a/core/Box.go +++ b/core/Box.go @@ -7,34 +7,34 @@ import ( ) const ( - CheckOk = "OK" - CheckErr = "FAILED" - eventMeasurementAge = "measurement_age" - eventMeasurementValMin = "measurement_min" - eventMeasurementValMax = "measurement_max" - eventMeasurementValSuspicious = "measurement_suspicious" - eventTargetAll = "all" // if event.Target is this value, all sensors will be checked + CheckOk = "OK" + CheckErr = "FAILED" + eventMeasurementAge = "measurement_age" + eventMeasurementValMin = "measurement_min" + eventMeasurementValMax = "measurement_max" + eventMeasurementValFaulty = "measurement_faulty" + eventTargetAll = "all" // if event.Target is this value, all sensors will be checked ) type checkType = struct{ description string } var checkTypes = map[string]checkType{ - eventMeasurementAge: checkType{"No measurement from %s since %s"}, - eventMeasurementValMin: checkType{"Sensor %s reads low value of %s"}, - eventMeasurementValMax: checkType{"Sensor %s reads high value of %s"}, - eventMeasurementValSuspicious: checkType{"Sensor %s reads presumably faulty value of %s"}, + eventMeasurementAge: checkType{"No measurement from %s since %s"}, + eventMeasurementValMin: checkType{"Sensor %s reads low value of %s"}, + eventMeasurementValMax: checkType{"Sensor %s reads high value of %s"}, + eventMeasurementValFaulty: checkType{"Sensor %s reads presumably faulty value of %s"}, } -type SuspiciousValue struct { +type FaultyValue struct { sensor string val float64 } -var suspiciousVals = map[SuspiciousValue]bool{ - SuspiciousValue{sensor: "BMP280", val: 0.0}: true, - SuspiciousValue{sensor: "HDC1008", val: 0.0}: true, - SuspiciousValue{sensor: "HDC1008", val: -40}: true, - SuspiciousValue{sensor: "SDS 011", val: 0.0}: true, +var faultyVals = map[FaultyValue]bool{ + FaultyValue{sensor: "BMP280", val: 0.0}: true, + FaultyValue{sensor: "HDC1008", val: 0.0}: true, + FaultyValue{sensor: "HDC1008", val: -40}: true, + FaultyValue{sensor: "SDS 011", val: 0.0}: true, } type NotifyEvent struct { @@ -71,20 +71,23 @@ func (box Box) RunChecks() ([]CheckResult, error) { var results = []CheckResult{} for _, event := range box.NotifyConf.Events { - target := event.Target - for _, s := range box.Sensors { // if a sensor never measured anything, thats ok. checks would fail anyway if s.LastMeasurement == nil { continue } - if target == eventTargetAll || target == s.Id { + // a validator must set these values + var ( + status = CheckOk + target = s.Id + value string + ) + + if event.Target == eventTargetAll || event.Target == s.Id { switch event.Type { case eventMeasurementAge: - // check if age of lastMeasurement is within threshold - status := CheckOk thresh, err := time.ParseDuration(event.Threshold) if err != nil { return nil, err @@ -93,16 +96,9 @@ func (box Box) RunChecks() ([]CheckResult, error) { status = CheckErr } - results = append(results, CheckResult{ - Threshold: event.Threshold, - Event: event.Type, - Target: s.Id, - Value: s.LastMeasurement.Date.String(), - Status: status, - }) + value = s.LastMeasurement.Date.String() case eventMeasurementValMin, eventMeasurementValMax: - status := CheckOk thresh, err := strconv.ParseFloat(event.Threshold, 64) if err != nil { return nil, err @@ -116,40 +112,34 @@ func (box Box) RunChecks() ([]CheckResult, error) { status = CheckErr } - results = append(results, CheckResult{ - Threshold: event.Threshold, - Event: event.Type, - Target: s.Id, - Value: s.LastMeasurement.Value, - Status: status, - }) - - case eventMeasurementValSuspicious: - status := CheckOk + value = s.LastMeasurement.Value + case eventMeasurementValFaulty: val, err := strconv.ParseFloat(s.LastMeasurement.Value, 64) if err != nil { return nil, err } - if suspiciousVals[SuspiciousValue{ + if faultyVals[FaultyValue{ sensor: s.Type, val: val, }] { status = CheckErr } - results = append(results, CheckResult{ - Threshold: event.Threshold, - Event: event.Type, - Target: s.Id, - Value: s.LastMeasurement.Value, - Status: status, - }) + value = s.LastMeasurement.Value } + + results = append(results, CheckResult{ + Threshold: event.Threshold, + Event: event.Type, + Target: target, + Value: value, + Status: status, + }) } } } - // must return ALL events to enable Notifier to clear previous notifications + return results, nil } diff --git a/core/notifiers.go b/core/notifiers.go index a0abe94..f979e0e 100644 --- a/core/notifiers.go +++ b/core/notifiers.go @@ -6,6 +6,8 @@ import ( "net/smtp" "strings" "time" + + "github.com/spf13/viper" ) var notifiers = map[string]AbstractNotifier{ @@ -23,21 +25,20 @@ type Notification struct { subject string } +// box config required for the EmailNotifier type EmailNotifier struct { - Recipients []string - FromAddress string + Recipients []string } func (n EmailNotifier) New(config interface{}) (AbstractNotifier, error) { res, ok := config.(EmailNotifier) - if !ok || res.Recipients == nil || res.FromAddress == "" { + if !ok || res.Recipients == nil { return nil, errors.New("Invalid EmailNotifier options") } return EmailNotifier{ - Recipients: res.Recipients, - FromAddress: res.FromAddress, + Recipients: res.Recipients, }, nil } @@ -55,24 +56,22 @@ func (n EmailNotifier) ComposeNotification(box *Box, checks []CheckResult) Notif } func (n EmailNotifier) Submit(notification Notification) error { - // Set up authentication information. TODO: load from config auth := smtp.PlainAuth( "", - "USERNAME", - "PASSWORD", - "SERVER", + viper.GetString("email.user"), + viper.GetString("email.pass"), + viper.GetString("email.host"), ) - fromAddress := "EXAMPLE@EXAMPLE.COM" - from := fmt.Sprintf("openSenseMap Notifier <%s>", fromAddress) - body := fmt.Sprintf("From: %s\nSubject: %s\nContent-Type: text/plain; charset=\"utf-8\"\n\n%s", from, notification.subject, notification.body) + from := viper.GetString("email.from") + body := fmt.Sprintf("From: openSenseMap Notifier <%s>\nSubject: %s\nContent-Type: text/plain; charset=\"utf-8\"\n\n%s", from, notification.subject, notification.body) // Connect to the server, authenticate, set the sender and recipient, // and send the email all in one step. err := smtp.SendMail( - "smtp.gmx.de:25", + fmt.Sprintf("%s:%s", viper.GetString("email.host"), viper.GetString("email.port")), auth, - fromAddress, + from, n.Recipients, []byte(body), )