From 2f4d58776d0f865d43e81960ffa14a7dfdadc690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Mon, 18 Apr 2022 19:37:13 +0200 Subject: [PATCH] fix(notifications): title customization (#1219) --- docs/notifications.md | 4 +- internal/flags/flags.go | 10 ++++ pkg/notifications/notifier.go | 55 +++++++++++++++------- pkg/notifications/notifier_test.go | 73 ++++++++++++++++++++++-------- pkg/notifications/shoutrrr.go | 30 +++++++----- pkg/notifications/shoutrrr_test.go | 24 ++++++++-- 6 files changed, 145 insertions(+), 51 deletions(-) diff --git a/docs/notifications.md b/docs/notifications.md index 1a99269..68647ab 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -28,6 +28,8 @@ comma-separated list of values to the `--notifications` option - `--notifications-hostname` (env. `WATCHTOWER_NOTIFICATIONS_HOSTNAME`): Custom hostname specified in subject/title. Useful to override the operating system hostname. - `--notifications-delay` (env. `WATCHTOWER_NOTIFICATION_DELAY`): Delay before sending notifications expressed in seconds. - Watchtower will post a notification every time it is started. This behavior [can be changed](https://containrrr.github.io/watchtower/arguments/#without_sending_a_startup_message) with an argument. +- `notification-title-tag` (env. `WATCHTOWER_NOTIFICATION_TITLE_TAG`): Prefix to include in the title. Useful when running multiple watchtowers. +- `notification-skip-title` (env. `WATCHTOWER_NOTIFICATION_SKIP_TITLE`): Do not pass the title param to notifications. This will not pass a dynamic title override to notification services. If no title is configured for the service, it will remove the title all together. ## Available services @@ -43,7 +45,7 @@ To receive notifications by email, the following command-line options, or their - `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with. - `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. Can also reference a file, in which case the contents of the file are used. - `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds. -- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers. +- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers. **NOTE:** This will affect all notification types. Example: diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 003ec30..664b669 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -326,6 +326,16 @@ Should only be used for testing.`) viper.GetBool("WATCHTOWER_NOTIFICATION_REPORT"), "Use the session report as the notification template data") + flags.StringP( + "notification-title-tag", + "", + viper.GetString("WATCHTOWER_NOTIFICATION_TITLE_TAG"), + "Title prefix tag for notifications") + + flags.Bool("notification-skip-title", + viper.GetBool("WATCHTOWER_NOTIFICATION_SKIP_TITLE"), + "Do not pass the title param to notifications") + flags.String( "warn-on-head-failure", viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"), diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go index cf03b50..d4c8601 100644 --- a/pkg/notifications/notifier.go +++ b/pkg/notifications/notifier.go @@ -2,6 +2,7 @@ package notifications import ( "os" + "strings" "time" ty "github.com/containrrr/watchtower/pkg/types" @@ -30,10 +31,10 @@ func NewNotifier(c *cobra.Command) ty.Notifier { tplString, _ := f.GetString("notification-template") urls, _ := f.GetStringArray("notification-url") - hostname := GetHostname(c) - urls, delay := AppendLegacyUrls(urls, c, GetTitle(hostname)) + data := GetTemplateData(c) + urls, delay := AppendLegacyUrls(urls, c, data.Title) - return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, hostname, delay, urls...) + return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, data, delay, urls...) } // AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags @@ -99,28 +100,50 @@ func GetDelay(c *cobra.Command, legacyDelay time.Duration) time.Duration { return time.Duration(0) } -// GetTitle returns a common notification title with hostname appended -func GetTitle(hostname string) string { - title := "Watchtower updates" +// GetTitle formats the title based on the passed hostname and tag +func GetTitle(hostname string, tag string) string { + tb := strings.Builder{} + + if tag != "" { + tb.WriteRune('[') + tb.WriteString(tag) + tb.WriteRune(']') + tb.WriteRune(' ') + } + + tb.WriteString("Watchtower updates") + if hostname != "" { - title += " on " + hostname + tb.WriteString(" on ") + tb.WriteString(hostname) } - return title -} -// GetHostname returns the hostname as set by args or resolved from OS -func GetHostname(c *cobra.Command) string { + return tb.String() +} +// GetTemplateData populates the static notification data from flags and environment +func GetTemplateData(c *cobra.Command) StaticData { f := c.PersistentFlags() + hostname, _ := f.GetString("notifications-hostname") + if hostname == "" { + hostname, _ = os.Hostname() + } - if hostname != "" { - return hostname - } else if hostname, err := os.Hostname(); err == nil { - return hostname + title := "" + if skip, _ := f.GetBool("notification-skip-title"); !skip { + tag, _ := f.GetString("notification-title-tag") + if tag == "" { + // For legacy email support + tag, _ = f.GetString("notification-email-subjecttag") + } + title = GetTitle(hostname, tag) } - return "" + return StaticData{ + Host: hostname, + Title: title, + } } // ColorHex is the default notification color used for services that support it (formatted as a CSS hex string) diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go index 4cb35a6..3ddb7f8 100644 --- a/pkg/notifications/notifier_test.go +++ b/pkg/notifications/notifier_test.go @@ -39,17 +39,58 @@ var _ = Describe("notifications", func() { "test.host", }) Expect(err).NotTo(HaveOccurred()) - hostname := notifications.GetHostname(command) - title := notifications.GetTitle(hostname) + data := notifications.GetTemplateData(command) + title := data.Title Expect(title).To(Equal("Watchtower updates on test.host")) }) }) When("no hostname can be resolved", func() { It("should use the default simple title", func() { - title := notifications.GetTitle("") + title := notifications.GetTitle("", "") Expect(title).To(Equal("Watchtower updates")) }) }) + When("title tag is set", func() { + It("should use the prefix in the title", func() { + command := cmd.NewRootCommand() + flags.RegisterNotificationFlags(command) + + Expect(command.ParseFlags([]string{ + "--notification-title-tag", + "PREFIX", + })).To(Succeed()) + + data := notifications.GetTemplateData(command) + Expect(data.Title).To(HavePrefix("[PREFIX]")) + }) + }) + When("legacy email tag is set", func() { + It("should use the prefix in the title", func() { + command := cmd.NewRootCommand() + flags.RegisterNotificationFlags(command) + + Expect(command.ParseFlags([]string{ + "--notification-email-subjecttag", + "PREFIX", + })).To(Succeed()) + + data := notifications.GetTemplateData(command) + Expect(data.Title).To(HavePrefix("[PREFIX]")) + }) + }) + When("the skip title flag is set", func() { + It("should return an empty title", func() { + command := cmd.NewRootCommand() + flags.RegisterNotificationFlags(command) + + Expect(command.ParseFlags([]string{ + "--notification-skip-title", + })).To(Succeed()) + + data := notifications.GetTemplateData(command) + Expect(data.Title).To(BeEmpty()) + }) + }) When("no delay is defined", func() { It("should use the default delay", func() { command := cmd.NewRootCommand() @@ -106,8 +147,8 @@ var _ = Describe("notifications", func() { channel := "123456789" token := "abvsihdbau" color := notifications.ColorInt - hostname := notifications.GetHostname(command) - title := url.QueryEscape(notifications.GetTitle(hostname)) + data := notifications.GetTemplateData(command) + title := url.QueryEscape(data.Title) expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&title=%s&username=watchtower", token, channel, color, title) buildArgs := func(url string) []string { return []string{ @@ -135,8 +176,8 @@ var _ = Describe("notifications", func() { tokenB := "BBBBBBBBB" tokenC := "123456789123456789123456" color := url.QueryEscape(notifications.ColorHex) - hostname := notifications.GetHostname(command) - title := url.QueryEscape(notifications.GetTitle(hostname)) + data := notifications.GetTemplateData(command) + title := url.QueryEscape(data.Title) iconURL := "https://containrrr.dev/watchtower-sq180.png" iconEmoji := "whale" @@ -194,8 +235,8 @@ var _ = Describe("notifications", func() { token := "aaa" host := "shoutrrr.local" - hostname := notifications.GetHostname(command) - title := url.QueryEscape(notifications.GetTitle(hostname)) + data := notifications.GetTemplateData(command) + title := url.QueryEscape(data.Title) expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title) @@ -223,8 +264,8 @@ var _ = Describe("notifications", func() { tokenB := "33333333012222222222333333333344" tokenC := "44444444-4444-4444-8444-cccccccccccc" color := url.QueryEscape(notifications.ColorHex) - hostname := notifications.GetHostname(command) - title := url.QueryEscape(notifications.GetTitle(hostname)) + data := notifications.GetTemplateData(command) + title := url.QueryEscape(data.Title) hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC) expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s&title=%s", tokenA, tokenB, tokenC, color, title) @@ -319,14 +360,10 @@ func testURL(args []string, expectedURL string, expectedDelay time.Duration) { command := cmd.NewRootCommand() flags.RegisterNotificationFlags(command) - err := command.ParseFlags(args) - Expect(err).NotTo(HaveOccurred()) + Expect(command.ParseFlags(args)).To(Succeed()) - hostname := notifications.GetHostname(command) - title := notifications.GetTitle(hostname) - urls, delay := notifications.AppendLegacyUrls([]string{}, command, title) - - Expect(err).NotTo(HaveOccurred()) + data := notifications.GetTemplateData(command) + urls, delay := notifications.AppendLegacyUrls([]string{}, command, data.Title) Expect(urls).To(ContainElement(expectedURL)) Expect(delay).To(Equal(expectedDelay)) diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go index bc9499e..3940d22 100644 --- a/pkg/notifications/shoutrrr.go +++ b/pkg/notifications/shoutrrr.go @@ -58,7 +58,7 @@ type shoutrrrTypeNotifier struct { done chan bool legacyTemplate bool params *types.Params - hostname string + data StaticData } // GetScheme returns the scheme part of a Shoutrrr URL @@ -79,11 +79,9 @@ func (n *shoutrrrTypeNotifier) GetNames() []string { return names } -func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, hostname string, delay time.Duration, urls ...string) t.Notifier { +func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, data StaticData, delay time.Duration, urls ...string) t.Notifier { - notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy) - notifier.hostname = hostname - notifier.params = &types.Params{"title": GetTitle(hostname)} + notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy, data) log.AddHook(notifier) // Do the sending in a separate goroutine so we don't block the main process. @@ -92,7 +90,7 @@ func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy return notifier } -func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool) *shoutrrrTypeNotifier { +func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool, data StaticData) *shoutrrrTypeNotifier { tpl, err := getShoutrrrTemplate(tplString, legacy) if err != nil { log.Errorf("Could not use configured notification template: %s. Using default template", err) @@ -104,6 +102,11 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error()) } + params := &types.Params{} + if data.Title != "" { + params.SetTitle(data.Title) + } + return &shoutrrrTypeNotifier{ Urls: urls, Router: r, @@ -112,6 +115,8 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy logLevels: levels, template: tpl, legacyTemplate: legacy, + data: data, + params: params, } } @@ -149,9 +154,7 @@ func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) { } func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) { - title, _ := n.params.Title() - host := n.hostname - msg, err := n.buildMessage(Data{entries, report, title, host}) + msg, err := n.buildMessage(Data{n.data, entries, report}) if msg == "" { // Log in go func in case we entered from Fire to avoid stalling @@ -240,10 +243,15 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, return } +// StaticData is the part of the notification template data model set upon initialization +type StaticData struct { + Title string + Host string +} + // Data is the notification template data model type Data struct { + StaticData Entries []*log.Entry Report t.Report - Title string - Host string } diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go index dbdd9eb..46ab78d 100644 --- a/pkg/notifications/shoutrrr_test.go +++ b/pkg/notifications/shoutrrr_test.go @@ -49,11 +49,14 @@ var mockDataAllFresh = Data{ func mockDataFromStates(states ...s.State) Data { hostname := "Mock" + prefix := "" return Data{ Entries: legacyMockData.Entries, Report: mocks.CreateMockProgressReport(states...), - Title: GetTitle(hostname), - Host: hostname, + StaticData: StaticData{ + Title: GetTitle(hostname, prefix), + Host: hostname, + }, } } @@ -77,7 +80,7 @@ var _ = Describe("Shoutrrr", func() { cmd := new(cobra.Command) flags.RegisterNotificationFlags(cmd) - shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true) + shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{}) entries := []*logrus.Entry{ { @@ -233,7 +236,7 @@ Turns out everything is on fire When("batching notifications", func() { When("no messages are queued", func() { It("should not send any notification", func() { - shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", time.Duration(0), "logger://") + shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://") shoutrrr.StartNotification() shoutrrr.SendNotification(nil) Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`)) @@ -241,7 +244,7 @@ Turns out everything is on fire }) When("at least one message is queued", func() { It("should send a notification", func() { - shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", time.Duration(0), "logger://") + shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://") shoutrrr.StartNotification() logrus.Info("This log message is sponsored by ContainrrrVPN") shoutrrr.SendNotification(nil) @@ -250,6 +253,17 @@ Turns out everything is on fire }) }) + When("the title data field is empty", func() { + It("should not have set the title param", func() { + shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{ + Host: "test.host", + Title: "", + }) + _, found := shoutrrr.params.Title() + Expect(found).ToNot(BeTrue()) + }) + }) + When("sending notifications", func() { It("SlowNotificationNotSent", func() {