Skip to content

Commit 2f4d587

Browse files
authored
fix(notifications): title customization (#1219)
1 parent e9c83af commit 2f4d587

File tree

6 files changed

+145
-51
lines changed

6 files changed

+145
-51
lines changed

docs/notifications.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ comma-separated list of values to the `--notifications` option
2828
- `--notifications-hostname` (env. `WATCHTOWER_NOTIFICATIONS_HOSTNAME`): Custom hostname specified in subject/title. Useful to override the operating system hostname.
2929
- `--notifications-delay` (env. `WATCHTOWER_NOTIFICATION_DELAY`): Delay before sending notifications expressed in seconds.
3030
- 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.
31+
- `notification-title-tag` (env. `WATCHTOWER_NOTIFICATION_TITLE_TAG`): Prefix to include in the title. Useful when running multiple watchtowers.
32+
- `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.
3133

3234
## Available services
3335

@@ -43,7 +45,7 @@ To receive notifications by email, the following command-line options, or their
4345
- `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with.
4446
- `--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.
4547
- `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds.
46-
- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers.
48+
- `--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.
4749

4850
Example:
4951

internal/flags/flags.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,16 @@ Should only be used for testing.`)
326326
viper.GetBool("WATCHTOWER_NOTIFICATION_REPORT"),
327327
"Use the session report as the notification template data")
328328

329+
flags.StringP(
330+
"notification-title-tag",
331+
"",
332+
viper.GetString("WATCHTOWER_NOTIFICATION_TITLE_TAG"),
333+
"Title prefix tag for notifications")
334+
335+
flags.Bool("notification-skip-title",
336+
viper.GetBool("WATCHTOWER_NOTIFICATION_SKIP_TITLE"),
337+
"Do not pass the title param to notifications")
338+
329339
flags.String(
330340
"warn-on-head-failure",
331341
viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"),

pkg/notifications/notifier.go

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package notifications
22

33
import (
44
"os"
5+
"strings"
56
"time"
67

78
ty "github.com/containrrr/watchtower/pkg/types"
@@ -30,10 +31,10 @@ func NewNotifier(c *cobra.Command) ty.Notifier {
3031
tplString, _ := f.GetString("notification-template")
3132
urls, _ := f.GetStringArray("notification-url")
3233

33-
hostname := GetHostname(c)
34-
urls, delay := AppendLegacyUrls(urls, c, GetTitle(hostname))
34+
data := GetTemplateData(c)
35+
urls, delay := AppendLegacyUrls(urls, c, data.Title)
3536

36-
return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, hostname, delay, urls...)
37+
return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, data, delay, urls...)
3738
}
3839

3940
// AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
@@ -99,28 +100,50 @@ func GetDelay(c *cobra.Command, legacyDelay time.Duration) time.Duration {
99100
return time.Duration(0)
100101
}
101102

102-
// GetTitle returns a common notification title with hostname appended
103-
func GetTitle(hostname string) string {
104-
title := "Watchtower updates"
103+
// GetTitle formats the title based on the passed hostname and tag
104+
func GetTitle(hostname string, tag string) string {
105+
tb := strings.Builder{}
106+
107+
if tag != "" {
108+
tb.WriteRune('[')
109+
tb.WriteString(tag)
110+
tb.WriteRune(']')
111+
tb.WriteRune(' ')
112+
}
113+
114+
tb.WriteString("Watchtower updates")
115+
105116
if hostname != "" {
106-
title += " on " + hostname
117+
tb.WriteString(" on ")
118+
tb.WriteString(hostname)
107119
}
108-
return title
109-
}
110120

111-
// GetHostname returns the hostname as set by args or resolved from OS
112-
func GetHostname(c *cobra.Command) string {
121+
return tb.String()
122+
}
113123

124+
// GetTemplateData populates the static notification data from flags and environment
125+
func GetTemplateData(c *cobra.Command) StaticData {
114126
f := c.PersistentFlags()
127+
115128
hostname, _ := f.GetString("notifications-hostname")
129+
if hostname == "" {
130+
hostname, _ = os.Hostname()
131+
}
116132

117-
if hostname != "" {
118-
return hostname
119-
} else if hostname, err := os.Hostname(); err == nil {
120-
return hostname
133+
title := ""
134+
if skip, _ := f.GetBool("notification-skip-title"); !skip {
135+
tag, _ := f.GetString("notification-title-tag")
136+
if tag == "" {
137+
// For legacy email support
138+
tag, _ = f.GetString("notification-email-subjecttag")
139+
}
140+
title = GetTitle(hostname, tag)
121141
}
122142

123-
return ""
143+
return StaticData{
144+
Host: hostname,
145+
Title: title,
146+
}
124147
}
125148

126149
// ColorHex is the default notification color used for services that support it (formatted as a CSS hex string)

pkg/notifications/notifier_test.go

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,58 @@ var _ = Describe("notifications", func() {
3939
"test.host",
4040
})
4141
Expect(err).NotTo(HaveOccurred())
42-
hostname := notifications.GetHostname(command)
43-
title := notifications.GetTitle(hostname)
42+
data := notifications.GetTemplateData(command)
43+
title := data.Title
4444
Expect(title).To(Equal("Watchtower updates on test.host"))
4545
})
4646
})
4747
When("no hostname can be resolved", func() {
4848
It("should use the default simple title", func() {
49-
title := notifications.GetTitle("")
49+
title := notifications.GetTitle("", "")
5050
Expect(title).To(Equal("Watchtower updates"))
5151
})
5252
})
53+
When("title tag is set", func() {
54+
It("should use the prefix in the title", func() {
55+
command := cmd.NewRootCommand()
56+
flags.RegisterNotificationFlags(command)
57+
58+
Expect(command.ParseFlags([]string{
59+
"--notification-title-tag",
60+
"PREFIX",
61+
})).To(Succeed())
62+
63+
data := notifications.GetTemplateData(command)
64+
Expect(data.Title).To(HavePrefix("[PREFIX]"))
65+
})
66+
})
67+
When("legacy email tag is set", func() {
68+
It("should use the prefix in the title", func() {
69+
command := cmd.NewRootCommand()
70+
flags.RegisterNotificationFlags(command)
71+
72+
Expect(command.ParseFlags([]string{
73+
"--notification-email-subjecttag",
74+
"PREFIX",
75+
})).To(Succeed())
76+
77+
data := notifications.GetTemplateData(command)
78+
Expect(data.Title).To(HavePrefix("[PREFIX]"))
79+
})
80+
})
81+
When("the skip title flag is set", func() {
82+
It("should return an empty title", func() {
83+
command := cmd.NewRootCommand()
84+
flags.RegisterNotificationFlags(command)
85+
86+
Expect(command.ParseFlags([]string{
87+
"--notification-skip-title",
88+
})).To(Succeed())
89+
90+
data := notifications.GetTemplateData(command)
91+
Expect(data.Title).To(BeEmpty())
92+
})
93+
})
5394
When("no delay is defined", func() {
5495
It("should use the default delay", func() {
5596
command := cmd.NewRootCommand()
@@ -106,8 +147,8 @@ var _ = Describe("notifications", func() {
106147
channel := "123456789"
107148
token := "abvsihdbau"
108149
color := notifications.ColorInt
109-
hostname := notifications.GetHostname(command)
110-
title := url.QueryEscape(notifications.GetTitle(hostname))
150+
data := notifications.GetTemplateData(command)
151+
title := url.QueryEscape(data.Title)
111152
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)
112153
buildArgs := func(url string) []string {
113154
return []string{
@@ -135,8 +176,8 @@ var _ = Describe("notifications", func() {
135176
tokenB := "BBBBBBBBB"
136177
tokenC := "123456789123456789123456"
137178
color := url.QueryEscape(notifications.ColorHex)
138-
hostname := notifications.GetHostname(command)
139-
title := url.QueryEscape(notifications.GetTitle(hostname))
179+
data := notifications.GetTemplateData(command)
180+
title := url.QueryEscape(data.Title)
140181
iconURL := "https://containrrr.dev/watchtower-sq180.png"
141182
iconEmoji := "whale"
142183

@@ -194,8 +235,8 @@ var _ = Describe("notifications", func() {
194235

195236
token := "aaa"
196237
host := "shoutrrr.local"
197-
hostname := notifications.GetHostname(command)
198-
title := url.QueryEscape(notifications.GetTitle(hostname))
238+
data := notifications.GetTemplateData(command)
239+
title := url.QueryEscape(data.Title)
199240

200241
expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
201242

@@ -223,8 +264,8 @@ var _ = Describe("notifications", func() {
223264
tokenB := "33333333012222222222333333333344"
224265
tokenC := "44444444-4444-4444-8444-cccccccccccc"
225266
color := url.QueryEscape(notifications.ColorHex)
226-
hostname := notifications.GetHostname(command)
227-
title := url.QueryEscape(notifications.GetTitle(hostname))
267+
data := notifications.GetTemplateData(command)
268+
title := url.QueryEscape(data.Title)
228269

229270
hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
230271
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) {
319360
command := cmd.NewRootCommand()
320361
flags.RegisterNotificationFlags(command)
321362

322-
err := command.ParseFlags(args)
323-
Expect(err).NotTo(HaveOccurred())
363+
Expect(command.ParseFlags(args)).To(Succeed())
324364

325-
hostname := notifications.GetHostname(command)
326-
title := notifications.GetTitle(hostname)
327-
urls, delay := notifications.AppendLegacyUrls([]string{}, command, title)
328-
329-
Expect(err).NotTo(HaveOccurred())
365+
data := notifications.GetTemplateData(command)
366+
urls, delay := notifications.AppendLegacyUrls([]string{}, command, data.Title)
330367

331368
Expect(urls).To(ContainElement(expectedURL))
332369
Expect(delay).To(Equal(expectedDelay))

pkg/notifications/shoutrrr.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ type shoutrrrTypeNotifier struct {
5858
done chan bool
5959
legacyTemplate bool
6060
params *types.Params
61-
hostname string
61+
data StaticData
6262
}
6363

6464
// GetScheme returns the scheme part of a Shoutrrr URL
@@ -79,11 +79,9 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
7979
return names
8080
}
8181

82-
func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, hostname string, delay time.Duration, urls ...string) t.Notifier {
82+
func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, data StaticData, delay time.Duration, urls ...string) t.Notifier {
8383

84-
notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy)
85-
notifier.hostname = hostname
86-
notifier.params = &types.Params{"title": GetTitle(hostname)}
84+
notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy, data)
8785
log.AddHook(notifier)
8886

8987
// 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
9290
return notifier
9391
}
9492

95-
func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool) *shoutrrrTypeNotifier {
93+
func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool, data StaticData) *shoutrrrTypeNotifier {
9694
tpl, err := getShoutrrrTemplate(tplString, legacy)
9795
if err != nil {
9896
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
104102
log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
105103
}
106104

105+
params := &types.Params{}
106+
if data.Title != "" {
107+
params.SetTitle(data.Title)
108+
}
109+
107110
return &shoutrrrTypeNotifier{
108111
Urls: urls,
109112
Router: r,
@@ -112,6 +115,8 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy
112115
logLevels: levels,
113116
template: tpl,
114117
legacyTemplate: legacy,
118+
data: data,
119+
params: params,
115120
}
116121
}
117122

@@ -149,9 +154,7 @@ func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) {
149154
}
150155

151156
func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
152-
title, _ := n.params.Title()
153-
host := n.hostname
154-
msg, err := n.buildMessage(Data{entries, report, title, host})
157+
msg, err := n.buildMessage(Data{n.data, entries, report})
155158

156159
if msg == "" {
157160
// 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,
240243
return
241244
}
242245

246+
// StaticData is the part of the notification template data model set upon initialization
247+
type StaticData struct {
248+
Title string
249+
Host string
250+
}
251+
243252
// Data is the notification template data model
244253
type Data struct {
254+
StaticData
245255
Entries []*log.Entry
246256
Report t.Report
247-
Title string
248-
Host string
249257
}

pkg/notifications/shoutrrr_test.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,14 @@ var mockDataAllFresh = Data{
4949

5050
func mockDataFromStates(states ...s.State) Data {
5151
hostname := "Mock"
52+
prefix := ""
5253
return Data{
5354
Entries: legacyMockData.Entries,
5455
Report: mocks.CreateMockProgressReport(states...),
55-
Title: GetTitle(hostname),
56-
Host: hostname,
56+
StaticData: StaticData{
57+
Title: GetTitle(hostname, prefix),
58+
Host: hostname,
59+
},
5760
}
5861
}
5962

@@ -77,7 +80,7 @@ var _ = Describe("Shoutrrr", func() {
7780
cmd := new(cobra.Command)
7881
flags.RegisterNotificationFlags(cmd)
7982

80-
shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true)
83+
shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{})
8184

8285
entries := []*logrus.Entry{
8386
{
@@ -233,15 +236,15 @@ Turns out everything is on fire
233236
When("batching notifications", func() {
234237
When("no messages are queued", func() {
235238
It("should not send any notification", func() {
236-
shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", time.Duration(0), "logger://")
239+
shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://")
237240
shoutrrr.StartNotification()
238241
shoutrrr.SendNotification(nil)
239242
Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
240243
})
241244
})
242245
When("at least one message is queued", func() {
243246
It("should send a notification", func() {
244-
shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", time.Duration(0), "logger://")
247+
shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://")
245248
shoutrrr.StartNotification()
246249
logrus.Info("This log message is sponsored by ContainrrrVPN")
247250
shoutrrr.SendNotification(nil)
@@ -250,6 +253,17 @@ Turns out everything is on fire
250253
})
251254
})
252255

256+
When("the title data field is empty", func() {
257+
It("should not have set the title param", func() {
258+
shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{
259+
Host: "test.host",
260+
Title: "",
261+
})
262+
_, found := shoutrrr.params.Title()
263+
Expect(found).ToNot(BeTrue())
264+
})
265+
})
266+
253267
When("sending notifications", func() {
254268

255269
It("SlowNotificationNotSent", func() {

0 commit comments

Comments
 (0)