Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@ notification properties based on the alert that the service is currently
handling. For example, if an alert is resolved, you may want to show a different
icon than when an alert is currently firing.

The title and description of the notification can be customized using [Go's
templating engine](https://pkg.go.dev/text/template). Refer to the definition of
the ``Alert`` struct in [format.go](internal/alertmanager/format.go) for the
data structure that is passed to the templates.
The title, description, and labels of the notification can be customized using
[Go's templating engine](https://pkg.go.dev/text/template). Refer to the
definition of the ``Alert`` struct in [format.go](internal/alertmanager/format.go)
for the data structure that is passed to the templates. The labels template also
has access to additional functions: ``split``, ``join``, ``trim``, ``lower``,
``upper``, ``capitalize``, ``contains``, ``hasPrefix``, ``hasSuffix``,
``replace``, and ``printf``.

```yaml
http:
Expand Down Expand Up @@ -71,6 +74,8 @@ ntfy:
{{ if eq .Status "resolved" }}Resolved: {{ end }}{{ index .Annotations "summary" }}
description: |
{{ index .Annotations "description" }}
labels: |
{{range $key, $value := .Labels}}{{$key}} = {{$value}}, {{end}}
headers:
X-Click: |
{{ .GeneratorURL }}
Expand Down
2 changes: 2 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ ntfy:
{{ if eq .Status "resolved" }}Resolved: {{ end }}{{ index .Annotations "summary" }}
description: |
{{ index .Annotations "description" }}
labels: |
{{range $key, $value := .Labels}}{{$key}} = {{$value}}, {{end}}
headers:
X-Click: |
{{ .GeneratorURL }}
Expand Down
29 changes: 28 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,42 @@ import (
"strings"
"text/template"
"time"
"unicode"
"unicode/utf8"

"github.com/PaesslerAG/gval"
"go.uber.org/zap"
)

// capitalize returns a string with the first character uppercased.
func capitalize(s string) string {
if s == "" {
return s
}
r, size := utf8.DecodeRuneInString(s)
return string(unicode.ToUpper(r)) + s[size:]
}

var (
exprLang = gval.Full()

// Source: https://github.com/binwiederhier/ntfy/blob/30301c8a7ff9e54ae505daf73a7f1571e7fefae3/user/types.go#L245
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)

// TemplateFuncs contains custom functions available in templates.
TemplateFuncs = template.FuncMap{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should use sprig instatt of develop the functio map on this own, see #25

"split": strings.Split,
"join": strings.Join,
"trim": strings.TrimSpace,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"capitalize": capitalize,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"replace": func(old, new, s string) string { return strings.ReplaceAll(s, old, new) },
"printf": fmt.Sprintf,
}
)

type Template template.Template
Expand All @@ -28,6 +54,7 @@ type Expression struct {
type Templates struct {
Title *Template `yaml:"title"`
Description *Template `yaml:"description"`
Labels *Template `yaml:"labels"`
Headers map[string]*Template `yaml:"headers"`
}

Expand Down Expand Up @@ -81,7 +108,7 @@ type Config struct {
func (t *Template) UnmarshalText(text []byte) error {
s := strings.TrimSpace(string(text))

tmpl, err := template.New("").Parse(s)
tmpl, err := template.New("").Funcs(TemplateFuncs).Parse(s)
if err != nil {
return err
}
Expand Down
117 changes: 117 additions & 0 deletions internal/server/labels_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package server

import (
"strings"
"testing"
"text/template"

"github.com/alexbakker/alertmanager-ntfy/internal/alertmanager"
"github.com/alexbakker/alertmanager-ntfy/internal/config"
)

func TestRenderLabelsTemplate(t *testing.T) {
tests := []struct {
name string
templateStr string
labels map[string]string
status string
expectedTags []string
}{
{
name: "nil template uses default behavior",
templateStr: "",
labels: map[string]string{"severity": "critical", "service": "api"},
expectedTags: []string{"severity = critical", "service = api"},
},
{
name: "empty template returns no tags",
templateStr: "{{/* empty */}}",
labels: map[string]string{"severity": "critical"},
expectedTags: []string{},
},
{
name: "custom format",
templateStr: "{{range $key, $value := .Labels}}{{$key}}: {{$value}}, {{end}}",
labels: map[string]string{"severity": "critical"},
expectedTags: []string{"severity: critical"},
},
{
name: "filter labels",
templateStr: "{{range $key, $value := .Labels}}{{if ne $key \"internal\"}}{{$key}}={{$value}}, {{end}}{{end}}",
labels: map[string]string{"severity": "critical", "internal": "debug"},
expectedTags: []string{"severity=critical"},
},
{
name: "capitalize function",
templateStr: "{{range $key, $value := .Labels}}{{ capitalize $value }}, {{end}}",
labels: map[string]string{"env": "production"},
expectedTags: []string{"Production"},
},
{
name: "firing alert with emoji label uses emoji label value",
templateStr: "{{- if eq .Status \"firing\" -}}{{- with index .Labels \"emoji\" -}}{{ . }}{{- else -}}rotating_light{{- end -}}{{- else -}}white_check_mark{{- end -}}",
labels: map[string]string{"emoji": "blue_car", "severity": "info"},
status: "firing",
expectedTags: []string{"blue_car"},
},
{
name: "firing alert without emoji label falls back to rotating_light",
templateStr: "{{- if eq .Status \"firing\" -}}{{- with index .Labels \"emoji\" -}}{{ . }}{{- else -}}rotating_light{{- end -}}{{- else -}}white_check_mark{{- end -}}",
labels: map[string]string{"severity": "warning"},
status: "firing",
expectedTags: []string{"rotating_light"},
},
{
name: "resolved alert emits white_check_mark",
templateStr: "{{- if eq .Status \"firing\" -}}{{- with index .Labels \"emoji\" -}}{{ . }}{{- else -}}rotating_light{{- end -}}{{- else -}}white_check_mark{{- end -}}",
labels: map[string]string{"severity": "info"},
status: "resolved",
expectedTags: []string{"white_check_mark"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
Ntfy: &config.Ntfy{
Notification: config.Notification{
Templates: &config.Templates{},
},
},
}

if tt.templateStr != "" {
tmpl, err := template.New("").Funcs(config.TemplateFuncs).Parse(tt.templateStr)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
cfg.Ntfy.Notification.Templates.Labels = (*config.Template)(tmpl)
}

server := &Server{cfg: cfg}
alert := &alertmanager.Alert{Labels: tt.labels, Status: tt.status}
ctx := &templateContext{Alert: alert}

tags, err := server.renderLabelsTemplate(ctx)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}

if len(tags) != len(tt.expectedTags) {
t.Errorf("Expected %d tags, got %d: %v", len(tt.expectedTags), len(tags), tags)
return
}

tagSet := make(map[string]bool)
for _, tag := range tags {
tagSet[strings.TrimSpace(tag)] = true
}

for _, expected := range tt.expectedTags {
if !tagSet[expected] {
t.Errorf("Expected tag %q not found in %v", expected, tags)
}
}
})
}
}
50 changes: 48 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
urlpkg "net/url"
"strings"
Expand Down Expand Up @@ -192,7 +193,15 @@ func (s *Server) forwardAlert(logger *zap.Logger, payload *alertmanager.Payload,

tags = append(tags, tag.Tag)
}
tags = append(tags, convertLabelsToTags(alert.Labels)...)
labelTags, err := s.renderLabelsTemplate(&tmplCtx)
if err != nil {
logger.Warn(
"Labels template rendering failed, falling back to default format",
zap.Error(err),
)
labelTags = convertLabelsToTags(alert.Labels)
}
tags = append(tags, labelTags...)

if title != "" {
req.Header.Set("X-Title", title)
Expand Down Expand Up @@ -232,7 +241,18 @@ func (s *Server) forwardAlert(logger *zap.Logger, payload *alertmanager.Payload,
defer res.Body.Close()

if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("http %d, %s", res.StatusCode, http.StatusText(res.StatusCode))
body, _ := io.ReadAll(io.LimitReader(res.Body, 1024))

logger.Error(
"ntfy returned non-2xx response",
zap.Int("status", res.StatusCode),
zap.String("response_body", string(body)),
zap.String("url", req.URL.String()),
zap.Any("headers", req.Header),
zap.String("body", description),
)

return fmt.Errorf("http %d: %s", res.StatusCode, string(body))
}

return nil
Expand Down Expand Up @@ -272,3 +292,29 @@ func evalStringExpr(expr *config.StringExpression, alert *alertmanager.Alert, pa

return expr.Text, nil
}

func (s *Server) renderLabelsTemplate(ctx *templateContext) ([]string, error) {
if s.cfg.Ntfy.Notification.Templates.Labels == nil {
return convertLabelsToTags(ctx.Labels), nil
}

var labelsBuf bytes.Buffer
if err := (*template.Template)(s.cfg.Ntfy.Notification.Templates.Labels).Execute(&labelsBuf, ctx); err != nil {
return nil, fmt.Errorf("render labels template: %w", err)
}

renderedLabels := strings.TrimSpace(labelsBuf.String())
if renderedLabels == "" {
return []string{}, nil
}

var tags []string
for _, tag := range strings.Split(renderedLabels, tagSeparator) {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}

return tags, nil
}