diff --git a/core/BoxCheckResults.go b/core/BoxCheckResults.go deleted file mode 100644 index 328d102..0000000 --- a/core/BoxCheckResults.go +++ /dev/null @@ -1,186 +0,0 @@ -package core - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - - log "github.com/sirupsen/logrus" - "github.com/spf13/viper" -) - -type CheckResult struct { - Status string - Event string - Target string - TargetName string - Value string - Threshold string -} - -func (r CheckResult) EventID() string { - s := fmt.Sprintf("%s%s%s", r.Event, r.Target, r.Threshold) - hasher := sha256.New() - hasher.Write([]byte(s)) - return hex.EncodeToString(hasher.Sum(nil)) -} - -func (r CheckResult) String() string { - if r.Status == CheckOk { - return fmt.Sprintf("%s %s (on sensor %s (%s) with value %s)\n", r.Event, r.Status, r.TargetName, r.Target, r.Value) - } else { - return fmt.Sprintf("%s: "+checkTypes[r.Event].description+"\n", r.Status, r.TargetName, r.Target, r.Value) - } -} - -type BoxCheckResults map[*Box][]CheckResult - -func (results BoxCheckResults) Size() int { - size := 0 - for _, boxResults := range results { - size += len(boxResults) - } - return size -} - -func (results BoxCheckResults) Log() { - for box, boxResults := range results { - boxLog := log.WithFields(log.Fields{ - "boxId": box.Id, - }) - countErr := 0 - for _, r := range boxResults { - resultLog := boxLog.WithFields(log.Fields{ - "status": r.Status, - "event": r.Event, - "value": r.Value, - "target": r.Target, - }) - if r.Status == CheckOk { - resultLog.Debugf("%s: %s", box.Name, r) - } else { - resultLog.Warnf("%s: %s", box.Name, r) - countErr++ - } - } - if countErr == 0 { - boxLog.Infof("%s: all is fine!", box.Name) - } - } -} - -func (results BoxCheckResults) SendNotifications() error { - // FIXME: don't return on errors, process all boxes first! - results = results.FilterChangedFromCache(false) - - n := results.Size() - if n == 0 { - log.Info("No notifications due.") - return nil - } else { - log.Infof("Notifying for %v checks turned bad in total...", results.Size()) - } - - for box, resultsDue := range results { - if len(resultsDue) == 0 { - continue - } - - transport := box.NotifyConf.Notifications.Transport - notifyLog := log.WithFields(log.Fields{ - "boxId": box.Id, - "transport": transport, - }) - - notifier, err2 := box.GetNotifier() - if err2 != nil { - notifyLog.Error(err2) - return err2 - } - notification := notifier.ComposeNotification(box, resultsDue) - err3 := notifier.Submit(notification) - if err3 != nil { - notifyLog.Error(err3) - return err3 - } - notifyLog.Infof("Sent notification for %s via %s with %v new issues", box.Name, transport, len(resultsDue)) - } - - return nil -} - -func (results BoxCheckResults) FilterChangedFromCache(keepOk bool) BoxCheckResults { - remaining := BoxCheckResults{} - - for box, boxResults := range results { - // get results from cache. they are indexed by an event ID per boxId - // filter, so that only changed result.Status remain - remaining[box] = []CheckResult{} - for _, result := range boxResults { - cached := viper.GetStringMap(fmt.Sprintf("watchcache.%s.%s", box.Id, result.EventID())) - if result.Status != cached["laststatus"] { - if result.Status != CheckOk || keepOk { - remaining[box] = append(remaining[box], result) - } - } - } - - // TODO: reminder functionality: extract additional results with Status ERR - // from cache with time.Since(lastNotifyDate) > remindAfter. - // would require to serialize the full result.. - } - - // upate cache, setting lastNotifyDate to Now() - for box, boxResults := range results { - for _, result := range boxResults { - // FIXME: somehow this is not persisted? - key := fmt.Sprintf("watchcache.%s.%s", box.Id, result.EventID()) - viper.Set(key+".laststatus", result.Status) - } - } - - return remaining -} - -func CheckBoxes(boxIds []string, defaultConf *NotifyConfig) (BoxCheckResults, error) { - log.Debug("Checking notifications for ", len(boxIds), " box(es)") - - results := BoxCheckResults{} - for _, boxId := range boxIds { - box, res, err := checkBox(boxId, defaultConf) - if err != nil { - return nil, err - } - results[box] = res - } - - return results, nil -} - -func checkBox(boxId string, defaultConf *NotifyConfig) (*Box, []CheckResult, error) { - boxLogger := log.WithFields(log.Fields{"boxId": boxId}) - boxLogger.Info("checking box for events") - - osem := NewOsemClient(viper.GetString("api")) - - // get box data - box, err := osem.GetBox(boxId) - if err != nil { - boxLogger.Error(err) - return nil, nil, err - } - - // if box has no notify config, we use the defaultConf - if box.NotifyConf == nil { - box.NotifyConf = defaultConf - } - - // run checks - results, err2 := box.RunChecks() - if err2 != nil { - boxLogger.Error("could not run checks on box: ", err2) - return box, results, err2 - } - - return box, results, nil -} diff --git a/core/OsemClient.go b/core/OsemClient.go deleted file mode 100644 index 65d4ec5..0000000 --- a/core/OsemClient.go +++ /dev/null @@ -1,34 +0,0 @@ -package core - -import ( - "errors" - "net/http" - - "github.com/dghubble/sling" -) - -type OsemError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -type OsemClient struct { - sling *sling.Sling -} - -func NewOsemClient(endpoint string) *OsemClient { - return &OsemClient{ - sling: sling.New().Client(&http.Client{}).Base(endpoint), - } -} - -func (client *OsemClient) GetBox(boxId string) (*Box, error) { - box := &Box{} - fail := &OsemError{} - client.sling.New().Path("boxes/").Path(boxId).Receive(box, fail) - if fail.Message != "" { - return box, errors.New("could not fetch box: " + fail.Message) - } - return box, nil -} - diff --git a/core/checkrunner.go b/core/checkrunner.go new file mode 100644 index 0000000..25a217c --- /dev/null +++ b/core/checkrunner.go @@ -0,0 +1,87 @@ +package core + +import ( + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +type BoxCheckResults map[*Box][]CheckResult + +func (results BoxCheckResults) Size() int { + size := 0 + for _, boxResults := range results { + size += len(boxResults) + } + return size +} + +func (results BoxCheckResults) Log() { + for box, boxResults := range results { + boxLog := log.WithFields(log.Fields{ + "boxId": box.Id, + }) + countErr := 0 + for _, r := range boxResults { + resultLog := boxLog.WithFields(log.Fields{ + "status": r.Status, + "event": r.Event, + "value": r.Value, + "target": r.Target, + }) + if r.Status == CheckOk { + resultLog.Debugf("%s: %s", box.Name, r) + } else { + resultLog.Warnf("%s: %s", box.Name, r) + countErr++ + } + } + if countErr == 0 { + boxLog.Infof("%s: all is fine!", box.Name) + } + } +} + +func CheckBoxes(boxIds []string, defaultConf *NotifyConfig) (BoxCheckResults, error) { + log.Debug("Checking notifications for ", len(boxIds), " box(es)") + + results := BoxCheckResults{} + for _, boxId := range boxIds { + // TODO: check boxes in parallel, capped at 5 at once + + box, res, err := checkBox(boxId, defaultConf) + if err != nil { + return nil, err + } + results[box] = res + } + + return results, nil +} + +func checkBox(boxId string, defaultConf *NotifyConfig) (*Box, []CheckResult, error) { + boxLogger := log.WithFields(log.Fields{"boxId": boxId}) + boxLogger.Info("checking box for events") + + osem := NewOsemClient(viper.GetString("api")) + + // get box data + box, err := osem.GetBox(boxId) + if err != nil { + boxLogger.Error(err) + return nil, nil, err + } + + // if box has no notify config, we use the defaultConf + if box.NotifyConf == nil { + box.NotifyConf = defaultConf + } + + // run checks + results, err2 := box.RunChecks() + if err2 != nil { + boxLogger.Error("could not run checks on box: ", err2) + return box, results, err2 + } + + return box, results, nil +} diff --git a/core/Box.go b/core/healthchecks.go similarity index 71% rename from core/Box.go rename to core/healthchecks.go index b70e3c4..e0200ed 100644 --- a/core/Box.go +++ b/core/healthchecks.go @@ -1,6 +1,8 @@ package core import ( + "crypto/sha256" + "encoding/hex" "fmt" "strconv" "time" @@ -37,35 +39,28 @@ var faultyVals = map[FaultyValue]bool{ FaultyValue{sensor: "SDS 011", val: 0.0}: true, } -type NotifyEvent struct { - Type string `json:"type"` - Target string `json:"target"` - Threshold string `json:"threshold"` +type CheckResult struct { + Status string + Event string + Target string + TargetName string + Value string + Threshold string } -type TransportConfig struct { - Transport string `json:"transport"` - Options interface{} `json:"options"` +func (r CheckResult) EventID() string { + s := fmt.Sprintf("%s%s%s", r.Event, r.Target, r.Threshold) + hasher := sha256.New() + hasher.Write([]byte(s)) + return hex.EncodeToString(hasher.Sum(nil)) } -type NotifyConfig struct { - Notifications TransportConfig `json:"notifications"` - Events []NotifyEvent `json:"events"` -} - -type Box struct { - Id string `json:"_id"` - Name string `json:"name"` - Sensors []struct { - Id string `json:"_id"` - Phenomenon string `json:"title"` - Type string `json:"sensorType"` - LastMeasurement *struct { - Value string `json:"value"` - Date time.Time `json:"createdAt"` - } `json:"lastMeasurement"` - } `json:"sensors"` - NotifyConf *NotifyConfig `json:"healthcheck"` +func (r CheckResult) String() string { + if r.Status == CheckOk { + return fmt.Sprintf("%s %s (on sensor %s (%s) with value %s)\n", r.Event, r.Status, r.TargetName, r.Target, r.Value) + } else { + return fmt.Sprintf("%s: "+checkTypes[r.Event].description+"\n", r.Status, r.TargetName, r.Target, r.Value) + } } func (box Box) RunChecks() ([]CheckResult, error) { @@ -145,17 +140,3 @@ func (box Box) RunChecks() ([]CheckResult, error) { return results, nil } - -func (box Box) GetNotifier() (AbstractNotifier, error) { - transport := box.NotifyConf.Notifications.Transport - if transport == "" { - return nil, fmt.Errorf("No notification transport provided") - } - - notifier := notifiers[transport] - if notifier == nil { - return nil, fmt.Errorf("%s is not a supported notification transport", transport) - } - - return notifier.New(box.NotifyConf.Notifications.Options) -} diff --git a/core/notifier_email.go b/core/notifier_email.go new file mode 100644 index 0000000..706d0a1 --- /dev/null +++ b/core/notifier_email.go @@ -0,0 +1,84 @@ +package core + +import ( + "errors" + "fmt" + "net/smtp" + "strings" + "time" + + "github.com/spf13/viper" +) + +// box config required for the EmailNotifier +type EmailNotifier struct { + Recipients []string +} + +func (n EmailNotifier) New(config interface{}) (AbstractNotifier, error) { + // assign configuration to the notifier after ensuring the correct type. + // lesson of this project: golang requires us to fuck around with type + // assertions, instead of providing us with proper inheritance. + + asserted, ok := config.(EmailNotifier) + if !ok || asserted.Recipients == nil { + // config did not contain valid options. + // first try fallback: parse result of viper is a map[string]interface{}, + // which requires a different assertion change + asserted2, ok := config.(map[string]interface{}) + if ok { + asserted3, ok := asserted2["recipients"].([]interface{}) + if ok { + asserted = EmailNotifier{Recipients: []string{}} + for _, rec := range asserted3 { + asserted.Recipients = append(asserted.Recipients, rec.(string)) + } + } + } + + if asserted.Recipients == nil { + return nil, errors.New("Invalid EmailNotifier options") + } + } + + return EmailNotifier{ + Recipients: asserted.Recipients, + }, nil +} + +func (n EmailNotifier) ComposeNotification(box *Box, checks []CheckResult) Notification { + resultTexts := []string{} + for _, check := range checks { + resultTexts = append(resultTexts, check.String()) + } + + return Notification{ + subject: fmt.Sprintf("Issues with your box \"%s\" on opensensemap.org!", box.Name), + body: fmt.Sprintf("A check at %s identified the following issue(s) with your box %s:\n\n%s\n\nYou may visit https://opensensemap.org/explore/%s for more details.\n\n--\nSent automatically by osem_notify (https://github.com/noerw/osem_notify)", + time.Now().Round(time.Minute), box.Name, strings.Join(resultTexts, "\n"), box.Id), + } +} + +func (n EmailNotifier) Submit(notification Notification) error { + auth := smtp.PlainAuth( + "", + viper.GetString("email.user"), + viper.GetString("email.pass"), + viper.GetString("email.host"), + ) + + 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( + fmt.Sprintf("%s:%s", viper.GetString("email.host"), viper.GetString("email.port")), + auth, + from, + n.Recipients, + []byte(body), + ) + + return err +} diff --git a/core/notifiers.go b/core/notifiers.go index 3418a4b..155a667 100644 --- a/core/notifiers.go +++ b/core/notifiers.go @@ -1,12 +1,9 @@ package core import ( - "errors" "fmt" - "net/smtp" - "strings" - "time" + log "github.com/sirupsen/logrus" "github.com/spf13/viper" ) @@ -25,75 +22,92 @@ type Notification struct { subject string } -// box config required for the EmailNotifier -type EmailNotifier struct { - Recipients []string +////// + +func (box Box) GetNotifier() (AbstractNotifier, error) { + transport := box.NotifyConf.Notifications.Transport + if transport == "" { + return nil, fmt.Errorf("No notification transport provided") + } + + notifier := notifiers[transport] + if notifier == nil { + return nil, fmt.Errorf("%s is not a supported notification transport", transport) + } + + return notifier.New(box.NotifyConf.Notifications.Options) } -func (n EmailNotifier) New(config interface{}) (AbstractNotifier, error) { - // assign configuration to the notifier after ensuring the correct type. - // lesson of this project: golang requires us to fuck around with type - // assertions, instead of providing us with proper inheritance. - - asserted, ok := config.(EmailNotifier) - if !ok || asserted.Recipients == nil { - // config did not contain valid options. - // first try fallback: parse result of viper is a map[string]interface{}, - // which requires a different assertion change - asserted2, ok := config.(map[string]interface{}) - if ok { - asserted3, ok := asserted2["recipients"].([]interface{}) - if ok { - asserted = EmailNotifier{Recipients: []string{}} - for _, rec := range asserted3 { - asserted.Recipients = append(asserted.Recipients, rec.(string)) - } - } +func (results BoxCheckResults) SendNotifications() error { + // FIXME: don't return on errors, process all boxes first! + // FIXME: only update cache when notifications sent successfully + results = results.FilterChangedFromCache(false) + + n := results.Size() + if n == 0 { + log.Info("No notifications due.") + return nil + } else { + log.Infof("Notifying for %v checks turned bad in total...", results.Size()) + } + + for box, resultsDue := range results { + if len(resultsDue) == 0 { + continue } - if asserted.Recipients == nil { - return nil, errors.New("Invalid EmailNotifier options") + transport := box.NotifyConf.Notifications.Transport + notifyLog := log.WithFields(log.Fields{ + "boxId": box.Id, + "transport": transport, + }) + + notifier, err2 := box.GetNotifier() + if err2 != nil { + notifyLog.Error(err2) + return err2 + } + notification := notifier.ComposeNotification(box, resultsDue) + err3 := notifier.Submit(notification) + if err3 != nil { + notifyLog.Error(err3) + return err3 } + notifyLog.Infof("Sent notification for %s via %s with %v new issues", box.Name, transport, len(resultsDue)) } - return EmailNotifier{ - Recipients: asserted.Recipients, - }, nil + return nil } -func (n EmailNotifier) ComposeNotification(box *Box, checks []CheckResult) Notification { - resultTexts := []string{} - for _, check := range checks { - resultTexts = append(resultTexts, check.String()) +func (results BoxCheckResults) FilterChangedFromCache(keepOk bool) BoxCheckResults { + remaining := BoxCheckResults{} + + for box, boxResults := range results { + // get results from cache. they are indexed by an event ID per boxId + // filter, so that only changed result.Status remain + remaining[box] = []CheckResult{} + for _, result := range boxResults { + cached := viper.GetStringMap(fmt.Sprintf("watchcache.%s.%s", box.Id, result.EventID())) + if result.Status != cached["laststatus"] { + if result.Status != CheckOk || keepOk { + remaining[box] = append(remaining[box], result) + } + } + } + + // TODO: reminder functionality: extract additional results with Status ERR + // from cache with time.Since(lastNotifyDate) > remindAfter. + // would require to serialize the full result.. } - return Notification{ - subject: fmt.Sprintf("Issues with your box \"%s\" on opensensemap.org!", box.Name), - body: fmt.Sprintf("A check at %s identified the following issue(s) with your box %s:\n\n%s\n\nYou may visit https://opensensemap.org/explore/%s for more details.\n\n--\nSent automatically by osem_notify (https://github.com/noerw/osem_notify)", - time.Now().Round(time.Minute), box.Name, strings.Join(resultTexts, "\n"), box.Id), + // upate cache, setting lastNotifyDate to Now() + for box, boxResults := range results { + for _, result := range boxResults { + // FIXME: somehow this is not persisted? + key := fmt.Sprintf("watchcache.%s.%s", box.Id, result.EventID()) + viper.Set(key+".laststatus", result.Status) + } } -} -func (n EmailNotifier) Submit(notification Notification) error { - auth := smtp.PlainAuth( - "", - viper.GetString("email.user"), - viper.GetString("email.pass"), - viper.GetString("email.host"), - ) - - 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( - fmt.Sprintf("%s:%s", viper.GetString("email.host"), viper.GetString("email.port")), - auth, - from, - n.Recipients, - []byte(body), - ) - - return err + return remaining } diff --git a/core/osem_api.go b/core/osem_api.go new file mode 100644 index 0000000..06f9331 --- /dev/null +++ b/core/osem_api.go @@ -0,0 +1,65 @@ +package core + +import ( + "errors" + "net/http" + "time" + + "github.com/dghubble/sling" +) + +type OsemError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type OsemClient struct { + sling *sling.Sling +} + +func NewOsemClient(endpoint string) *OsemClient { + return &OsemClient{ + sling: sling.New().Client(&http.Client{}).Base(endpoint), + } +} + +func (client *OsemClient) GetBox(boxId string) (*Box, error) { + box := &Box{} + fail := &OsemError{} + client.sling.New().Path("boxes/").Path(boxId).Receive(box, fail) + if fail.Message != "" { + return box, errors.New("could not fetch box: " + fail.Message) + } + return box, nil +} + +type NotifyEvent struct { + Type string `json:"type"` + Target string `json:"target"` + Threshold string `json:"threshold"` +} + +type TransportConfig struct { + Transport string `json:"transport"` + Options interface{} `json:"options"` +} + +type NotifyConfig struct { + Notifications TransportConfig `json:"notifications"` + Events []NotifyEvent `json:"events"` +} + +type Box struct { + Id string `json:"_id"` + Name string `json:"name"` + Sensors []struct { + Id string `json:"_id"` + Phenomenon string `json:"title"` + Type string `json:"sensorType"` + LastMeasurement *struct { + Value string `json:"value"` + Date time.Time `json:"createdAt"` + } `json:"lastMeasurement"` + } `json:"sensors"` + NotifyConf *NotifyConfig `json:"healthcheck"` +}