fix(notifications): title customization (#1219)

pull/1277/head
nils måsén 2 years ago committed by GitHub
parent e9c83af533
commit 2f4d58776d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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-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. - `--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. - 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 ## 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-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-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-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: Example:

@ -326,6 +326,16 @@ Should only be used for testing.`)
viper.GetBool("WATCHTOWER_NOTIFICATION_REPORT"), viper.GetBool("WATCHTOWER_NOTIFICATION_REPORT"),
"Use the session report as the notification template data") "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( flags.String(
"warn-on-head-failure", "warn-on-head-failure",
viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"), viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"),

@ -2,6 +2,7 @@ package notifications
import ( import (
"os" "os"
"strings"
"time" "time"
ty "github.com/containrrr/watchtower/pkg/types" ty "github.com/containrrr/watchtower/pkg/types"
@ -30,10 +31,10 @@ func NewNotifier(c *cobra.Command) ty.Notifier {
tplString, _ := f.GetString("notification-template") tplString, _ := f.GetString("notification-template")
urls, _ := f.GetStringArray("notification-url") urls, _ := f.GetStringArray("notification-url")
hostname := GetHostname(c) data := GetTemplateData(c)
urls, delay := AppendLegacyUrls(urls, c, GetTitle(hostname)) 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 // 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) return time.Duration(0)
} }
// GetTitle returns a common notification title with hostname appended // GetTitle formats the title based on the passed hostname and tag
func GetTitle(hostname string) string { func GetTitle(hostname string, tag string) string {
title := "Watchtower updates" tb := strings.Builder{}
if tag != "" {
tb.WriteRune('[')
tb.WriteString(tag)
tb.WriteRune(']')
tb.WriteRune(' ')
}
tb.WriteString("Watchtower updates")
if hostname != "" { 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 return tb.String()
func GetHostname(c *cobra.Command) string { }
// GetTemplateData populates the static notification data from flags and environment
func GetTemplateData(c *cobra.Command) StaticData {
f := c.PersistentFlags() f := c.PersistentFlags()
hostname, _ := f.GetString("notifications-hostname") hostname, _ := f.GetString("notifications-hostname")
if hostname == "" {
hostname, _ = os.Hostname()
}
if hostname != "" { title := ""
return hostname if skip, _ := f.GetBool("notification-skip-title"); !skip {
} else if hostname, err := os.Hostname(); err == nil { tag, _ := f.GetString("notification-title-tag")
return hostname 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) // ColorHex is the default notification color used for services that support it (formatted as a CSS hex string)

@ -39,17 +39,58 @@ var _ = Describe("notifications", func() {
"test.host", "test.host",
}) })
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
hostname := notifications.GetHostname(command) data := notifications.GetTemplateData(command)
title := notifications.GetTitle(hostname) title := data.Title
Expect(title).To(Equal("Watchtower updates on test.host")) Expect(title).To(Equal("Watchtower updates on test.host"))
}) })
}) })
When("no hostname can be resolved", func() { When("no hostname can be resolved", func() {
It("should use the default simple title", func() { It("should use the default simple title", func() {
title := notifications.GetTitle("") title := notifications.GetTitle("", "")
Expect(title).To(Equal("Watchtower updates")) 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() { When("no delay is defined", func() {
It("should use the default delay", func() { It("should use the default delay", func() {
command := cmd.NewRootCommand() command := cmd.NewRootCommand()
@ -106,8 +147,8 @@ var _ = Describe("notifications", func() {
channel := "123456789" channel := "123456789"
token := "abvsihdbau" token := "abvsihdbau"
color := notifications.ColorInt color := notifications.ColorInt
hostname := notifications.GetHostname(command) data := notifications.GetTemplateData(command)
title := url.QueryEscape(notifications.GetTitle(hostname)) 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) 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 { buildArgs := func(url string) []string {
return []string{ return []string{
@ -135,8 +176,8 @@ var _ = Describe("notifications", func() {
tokenB := "BBBBBBBBB" tokenB := "BBBBBBBBB"
tokenC := "123456789123456789123456" tokenC := "123456789123456789123456"
color := url.QueryEscape(notifications.ColorHex) color := url.QueryEscape(notifications.ColorHex)
hostname := notifications.GetHostname(command) data := notifications.GetTemplateData(command)
title := url.QueryEscape(notifications.GetTitle(hostname)) title := url.QueryEscape(data.Title)
iconURL := "https://containrrr.dev/watchtower-sq180.png" iconURL := "https://containrrr.dev/watchtower-sq180.png"
iconEmoji := "whale" iconEmoji := "whale"
@ -194,8 +235,8 @@ var _ = Describe("notifications", func() {
token := "aaa" token := "aaa"
host := "shoutrrr.local" host := "shoutrrr.local"
hostname := notifications.GetHostname(command) data := notifications.GetTemplateData(command)
title := url.QueryEscape(notifications.GetTitle(hostname)) title := url.QueryEscape(data.Title)
expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title) expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
@ -223,8 +264,8 @@ var _ = Describe("notifications", func() {
tokenB := "33333333012222222222333333333344" tokenB := "33333333012222222222333333333344"
tokenC := "44444444-4444-4444-8444-cccccccccccc" tokenC := "44444444-4444-4444-8444-cccccccccccc"
color := url.QueryEscape(notifications.ColorHex) color := url.QueryEscape(notifications.ColorHex)
hostname := notifications.GetHostname(command) data := notifications.GetTemplateData(command)
title := url.QueryEscape(notifications.GetTitle(hostname)) title := url.QueryEscape(data.Title)
hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC) 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) 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() command := cmd.NewRootCommand()
flags.RegisterNotificationFlags(command) flags.RegisterNotificationFlags(command)
err := command.ParseFlags(args) Expect(command.ParseFlags(args)).To(Succeed())
Expect(err).NotTo(HaveOccurred())
hostname := notifications.GetHostname(command) data := notifications.GetTemplateData(command)
title := notifications.GetTitle(hostname) urls, delay := notifications.AppendLegacyUrls([]string{}, command, data.Title)
urls, delay := notifications.AppendLegacyUrls([]string{}, command, title)
Expect(err).NotTo(HaveOccurred())
Expect(urls).To(ContainElement(expectedURL)) Expect(urls).To(ContainElement(expectedURL))
Expect(delay).To(Equal(expectedDelay)) Expect(delay).To(Equal(expectedDelay))

@ -58,7 +58,7 @@ type shoutrrrTypeNotifier struct {
done chan bool done chan bool
legacyTemplate bool legacyTemplate bool
params *types.Params params *types.Params
hostname string data StaticData
} }
// GetScheme returns the scheme part of a Shoutrrr URL // GetScheme returns the scheme part of a Shoutrrr URL
@ -79,11 +79,9 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
return names 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 := createNotifier(urls, acceptedLogLevels, tplString, legacy, data)
notifier.hostname = hostname
notifier.params = &types.Params{"title": GetTitle(hostname)}
log.AddHook(notifier) log.AddHook(notifier)
// Do the sending in a separate goroutine so we don't block the main process. // 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 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) tpl, err := getShoutrrrTemplate(tplString, legacy)
if err != nil { if err != nil {
log.Errorf("Could not use configured notification template: %s. Using default template", err) 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()) log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
} }
params := &types.Params{}
if data.Title != "" {
params.SetTitle(data.Title)
}
return &shoutrrrTypeNotifier{ return &shoutrrrTypeNotifier{
Urls: urls, Urls: urls,
Router: r, Router: r,
@ -112,6 +115,8 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy
logLevels: levels, logLevels: levels,
template: tpl, template: tpl,
legacyTemplate: legacy, 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) { func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
title, _ := n.params.Title() msg, err := n.buildMessage(Data{n.data, entries, report})
host := n.hostname
msg, err := n.buildMessage(Data{entries, report, title, host})
if msg == "" { if msg == "" {
// Log in go func in case we entered from Fire to avoid stalling // 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 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 // Data is the notification template data model
type Data struct { type Data struct {
StaticData
Entries []*log.Entry Entries []*log.Entry
Report t.Report Report t.Report
Title string
Host string
} }

@ -49,11 +49,14 @@ var mockDataAllFresh = Data{
func mockDataFromStates(states ...s.State) Data { func mockDataFromStates(states ...s.State) Data {
hostname := "Mock" hostname := "Mock"
prefix := ""
return Data{ return Data{
Entries: legacyMockData.Entries, Entries: legacyMockData.Entries,
Report: mocks.CreateMockProgressReport(states...), Report: mocks.CreateMockProgressReport(states...),
Title: GetTitle(hostname), StaticData: StaticData{
Host: hostname, Title: GetTitle(hostname, prefix),
Host: hostname,
},
} }
} }
@ -77,7 +80,7 @@ var _ = Describe("Shoutrrr", func() {
cmd := new(cobra.Command) cmd := new(cobra.Command)
flags.RegisterNotificationFlags(cmd) flags.RegisterNotificationFlags(cmd)
shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true) shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{})
entries := []*logrus.Entry{ entries := []*logrus.Entry{
{ {
@ -233,7 +236,7 @@ Turns out everything is on fire
When("batching notifications", func() { When("batching notifications", func() {
When("no messages are queued", func() { When("no messages are queued", func() {
It("should not send any notification", 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.StartNotification()
shoutrrr.SendNotification(nil) shoutrrr.SendNotification(nil)
Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`)) Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
@ -241,7 +244,7 @@ Turns out everything is on fire
}) })
When("at least one message is queued", func() { When("at least one message is queued", func() {
It("should send a notification", 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() shoutrrr.StartNotification()
logrus.Info("This log message is sponsored by ContainrrrVPN") logrus.Info("This log message is sponsored by ContainrrrVPN")
shoutrrr.SendNotification(nil) 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() { When("sending notifications", func() {
It("SlowNotificationNotSent", func() { It("SlowNotificationNotSent", func() {

Loading…
Cancel
Save