From 6b035f4deb65280a0ce9059622dff37e6ea0d06a Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 13:44:16 +0200 Subject: [PATCH 01/23] feat: notify ReconciliationSucceeded only after modification --- bridge/flux.go | 57 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/bridge/flux.go b/bridge/flux.go index f66287f..e82f632 100644 --- a/bridge/flux.go +++ b/bridge/flux.go @@ -9,20 +9,26 @@ import ( "time" ) +type fluxInvolvedObject struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + UID string `json:"uid"` + APIVersion string `json:"apiVersion"` + ResourceVersion string `json:"resourceVersion"` +} + +func (f fluxInvolvedObject) String() string { + return strings.ToLower(f.Kind) + "/" + f.Namespace + "." + f.Name +} + type FluxNotification struct { - InvolvedObject struct { - Kind string `json:"kind"` - Namespace string `json:"namespace"` - Name string `json:"name"` - UID string `json:"uid"` - APIVersion string `json:"apiVersion"` - ResourceVersion string `json:"resourceVersion"` - } `json:"involvedObject"` - Severity string `json:"severity"` - Timestamp time.Time `json:"timestamp"` - Message string `json:"message"` - Reason string `json:"reason"` - Metadata struct { + InvolvedObject fluxInvolvedObject `json:"involvedObject"` + Severity string `json:"severity"` + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` + Reason string `json:"reason"` + Metadata struct { CommitStatus string `json:"commit_status"` Revision string `json:"revision"` Summary string `json:"summary"` @@ -31,10 +37,15 @@ type FluxNotification struct { ReportingInstance string `json:"reportingInstance"` } -type FluxHandler struct{} +type FluxHandler struct { + // Register all modifications of reconciliations + reconciliations map[string]bool +} func NewFluxHandler() FluxHandler { - return FluxHandler{} + return FluxHandler{ + reconciliations: make(map[string]bool), + } } func (f FluxHandler) FormatNotification(r io.Reader) (Notification, error) { @@ -47,13 +58,21 @@ func (f FluxHandler) FormatNotification(r io.Reader) (Notification, error) { return Notification{}, err } + obj := not.InvolvedObject.String() if not.Reason == "ReconciliationSucceeded" { - // Filter out spammy ReconciliationSucceeded notification - return Notification{}, nil + if ok := f.reconciliations[obj]; !ok { + // Filter out spammy ReconciliationSucceeded notification + return Notification{}, nil + } + + // we will print the object so skip it next time it spam + f.reconciliations[obj] = false + } else { + // object has been modified, we can print it next time + f.reconciliations[obj] = true } - title := fmt.Sprintf("[%s] %s %s/%s.%s", not.Severity, not.Reason, - strings.ToLower(not.InvolvedObject.Kind), not.InvolvedObject.Namespace, not.InvolvedObject.Name) + title := fmt.Sprintf("[%s] %s %s", not.Severity, not.Reason, obj) body := not.Message + "\n\n**revision**\n" + not.Metadata.Revision l.Debug("flux notification", slog.Group("notification", From 9f303dcb898423b54ccbb32636e322767e4fd5b0 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 13:51:15 +0200 Subject: [PATCH 02/23] chore: add dockerignore --- .dockerignore | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dae6265 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +/.git +/k8s +/flake.* +/LICENSE +/dist +/renovate.json +/skaffold.yaml From 917d7571f2962ffd0ca0967557c207e7e6ba6ff5 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 14:57:43 +0200 Subject: [PATCH 03/23] refactor: skip notification via err --- bridge/bridge.go | 16 +++++++++------- bridge/flux.go | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index f9842bc..2fd6204 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -11,6 +11,10 @@ import ( "strings" ) +var ( + errSkipNotification = errors.New("notification skipped") +) + type Handler interface { FormatNotification(r io.Reader) (Notification, error) } @@ -131,16 +135,14 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { not, err := b.h.FormatNotification(r.Body) defer r.Body.Close() - if err != nil { - slog.Error("failed to format notification") - w.WriteHeader(http.StatusBadRequest) + if errors.Is(err, errSkipNotification) { + w.WriteHeader(http.StatusOK) return } - // If notification is empty, that means it should be ignored. - // TODO: maybe return an error instead of empty notification - if not.IsEmpty() { - w.WriteHeader(http.StatusOK) + if err != nil { + slog.Error("failed to format notification") + w.WriteHeader(http.StatusBadRequest) return } diff --git a/bridge/flux.go b/bridge/flux.go index e82f632..357d447 100644 --- a/bridge/flux.go +++ b/bridge/flux.go @@ -62,7 +62,7 @@ func (f FluxHandler) FormatNotification(r io.Reader) (Notification, error) { if not.Reason == "ReconciliationSucceeded" { if ok := f.reconciliations[obj]; !ok { // Filter out spammy ReconciliationSucceeded notification - return Notification{}, nil + return Notification{}, errSkipNotification } // we will print the object so skip it next time it spam From ec3c0cfedd14c680175303081aaee687792d1d5a Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 14:58:00 +0200 Subject: [PATCH 04/23] fix: use correct service for ntfy --- k8s/bridge.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/bridge.yaml b/k8s/bridge.yaml index 68be542..5f393b7 100644 --- a/k8s/bridge.yaml +++ b/k8s/bridge.yaml @@ -10,7 +10,7 @@ data: log-format text ntfy { - server http://ntfy:80 + server http://ntfy-http:80 } handler "/flux" { @@ -54,6 +54,6 @@ spec: ports: - port: 8080 name: http - type: LoadBalancer + type: ClusterIP selector: app: bridge From 377cb91c67f123841ee30d10d8af3f76f640d629 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 14:58:38 +0200 Subject: [PATCH 05/23] chore: update bridge http address --- k8s/flux.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/flux.yaml b/k8s/flux.yaml index f43fec9..294c7d4 100644 --- a/k8s/flux.yaml +++ b/k8s/flux.yaml @@ -13,7 +13,7 @@ kind: Secret metadata: name: ntfy-bridge-dev-address stringData: - address: "http://bridge.ntfy-bridge-dev.svc.cluster.local:8080/flux" + address: "http://bridge:8080/flux" --- apiVersion: notification.toolkit.fluxcd.io/v1beta2 kind: Alert From 8c3cd0f286edd5c55dfac81d0075399d31aab014 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 14:58:47 +0200 Subject: [PATCH 06/23] chore: enable local build in skaffold --- skaffold.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/skaffold.yaml b/skaffold.yaml index 22c9473..f85d4b3 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -6,6 +6,7 @@ build: artifacts: - image: forge.babariviere.com/babariviere/ntfy-bridge buildpacks: + local: {} manifests: kustomize: paths: From 1b48840c8b7c795256340a9354417a2ba83045df Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 16:36:26 +0200 Subject: [PATCH 07/23] chore: return 204 when notification is send --- bridge/bridge.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index 2fd6204..3ee95fb 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -136,7 +136,7 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() if errors.Is(err, errSkipNotification) { - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) return } @@ -156,5 +156,5 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { slog.Debug("notification sent with success") - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } From ea22fa5569c9f2f29692c9b24ebb18132a6d66f4 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 16:37:02 +0200 Subject: [PATCH 08/23] refactor: make handlers able to send multiple notifications at once --- bridge/bridge.go | 22 +++++++++++----------- bridge/flux.go | 17 +++++++++-------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index 3ee95fb..dbd7d9f 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "encoding/json" "errors" - "io" "log/slog" "net/http" "strconv" @@ -16,7 +15,7 @@ var ( ) type Handler interface { - FormatNotification(r io.Reader) (Notification, error) + ProduceNotifications(r *http.Request) ([]Notification, error) } type NotificationError struct { @@ -132,8 +131,7 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - not, err := b.h.FormatNotification(r.Body) - defer r.Body.Close() + nots, err := b.h.ProduceNotifications(r) if errors.Is(err, errSkipNotification) { w.WriteHeader(http.StatusNoContent) @@ -146,15 +144,17 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - not.topic = b.topic - not.auth = b.auth - if err = not.Send(b.baseURL); err != nil { - slog.Error("unable to send notification", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return + for _, not := range nots { + not.topic = b.topic + not.auth = b.auth + if err = not.Send(b.baseURL); err != nil { + slog.Error("unable to send notification", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } } - slog.Debug("notification sent with success") + slog.Debug("notifications sent with success", "sent", len(nots)) w.WriteHeader(http.StatusNoContent) } diff --git a/bridge/flux.go b/bridge/flux.go index 357d447..0088240 100644 --- a/bridge/flux.go +++ b/bridge/flux.go @@ -3,8 +3,8 @@ package bridge import ( "encoding/json" "fmt" - "io" "log/slog" + "net/http" "strings" "time" ) @@ -48,21 +48,22 @@ func NewFluxHandler() FluxHandler { } } -func (f FluxHandler) FormatNotification(r io.Reader) (Notification, error) { +func (f FluxHandler) ProduceNotifications(r *http.Request) ([]Notification, error) { l := slog.With(slog.String("handler", "flux")) - dec := json.NewDecoder(r) + dec := json.NewDecoder(r.Body) + defer r.Body.Close() var not FluxNotification if err := dec.Decode(¬); err != nil { - l.Error("invalid message format in flux", "error", err) - return Notification{}, err + l.Error("invalid message format", "error", err) + return nil, err } obj := not.InvolvedObject.String() if not.Reason == "ReconciliationSucceeded" { if ok := f.reconciliations[obj]; !ok { // Filter out spammy ReconciliationSucceeded notification - return Notification{}, errSkipNotification + return nil, errSkipNotification } // we will print the object so skip it next time it spam @@ -78,9 +79,9 @@ func (f FluxHandler) FormatNotification(r io.Reader) (Notification, error) { l.Debug("flux notification", slog.Group("notification", slog.String("title", title), slog.String("body", body))) - return Notification{ + return []Notification{{ Title: title, Body: body, IsMarkdown: true, - }, nil + }}, nil } From ac2940ccfe464f14b6c9243ae16c30a259190708 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 16:38:03 +0200 Subject: [PATCH 09/23] feat: add support for notification action --- bridge/bridge.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/bridge/bridge.go b/bridge/bridge.go index dbd7d9f..29b7b2d 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -23,11 +23,37 @@ type NotificationError struct { Error string `json:"error"` } +type NotificationAction struct { + Action string + Label string + Params []string +} + +func NewViewAction(label, url string, params ...string) NotificationAction { + return NotificationAction{ + Action: "view", + Label: label, + Params: append([]string{url}, params...), + } +} + +func (n NotificationAction) Format() string { + var sb strings.Builder + sb.WriteString(n.Action + ", " + n.Label) + + if len(n.Params) > 0 { + sb.WriteString(", " + strings.Join(n.Params, ", ")) + } + + return sb.String() +} + type Notification struct { Title string Body string Priority int Tags []string + Actions []NotificationAction IsMarkdown bool topic string @@ -70,6 +96,15 @@ func (n Notification) Send(base string) error { req.Header.Set("Authorization", n.auth.bearer()) } + if len(n.Actions) > 0 { + actions := make([]string, len(n.Actions)) + for i, act := range n.Actions { + actions[i] = act.Format() + } + + req.Header.Set("Actions", strings.Join(actions, "; ")) + } + resp, err := http.DefaultClient.Do(req) if err != nil { return err From 64e91cb2d4bad95d1cda4a21c766cabb2c3f031b Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 16:45:46 +0200 Subject: [PATCH 10/23] fix: use default topic if none is given --- main.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index a07caba..8ea1926 100644 --- a/main.go +++ b/main.go @@ -77,8 +77,11 @@ func main() { } slog.Debug("Registering bridge", "route", route, "handler", handler.Type) - // TODO: use default topic if topic == "" - bridge := bridge.NewBridge(cfg.Ntfy.Server, handler.Topic, h) + topic := handler.Topic + if topic == "" { + topic = cfg.Ntfy.DefaultTopic + } + bridge := bridge.NewBridge(cfg.Ntfy.Server, topic, h) if !auth.IsEmpty() { bridge.WithAuth(auth) } From c832ea4645c4770ee641c062d8cb3fdc0592b87d Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 16:46:02 +0200 Subject: [PATCH 11/23] feat: add discord_embed handler --- bridge/discord.go | 74 +++++++++++++++++++++++++++++++++++++++++++++ config.example.scfg | 9 ++++++ config/config.go | 5 +++ k8s/bridge.yaml | 5 +++ main.go | 2 ++ 5 files changed, 95 insertions(+) create mode 100644 bridge/discord.go diff --git a/bridge/discord.go b/bridge/discord.go new file mode 100644 index 0000000..f2d423f --- /dev/null +++ b/bridge/discord.go @@ -0,0 +1,74 @@ +package bridge + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" +) + +type DiscordMessage struct { + Content string `json:"content"` + Embeds []struct { + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Footer struct { + Text string `json:"text"` + } `json:"footer"` + Author struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"author"` + } `json:"embeds"` +} + +type DiscordEmbedHandler struct{} + +func NewDiscordEmbedHandler() DiscordEmbedHandler { + return DiscordEmbedHandler{} +} + +func (d DiscordEmbedHandler) ProduceNotifications(r *http.Request) ([]Notification, error) { + l := slog.With(slog.String("handler", "discord_embed")) + + dec := json.NewDecoder(r.Body) + defer r.Body.Close() + + var not DiscordMessage + if err := dec.Decode(¬); err != nil { + l.Error("invalid message format", "error", err) + return nil, err + } + + notifications := make([]Notification, len(not.Embeds)) + for i, embed := range not.Embeds { + not := notifications[i] + not.Title = embed.Title + not.IsMarkdown = true + if embed.URL != "" { + not.Actions = []NotificationAction{NewViewAction("Open in Browser", embed.URL)} + } + + var body strings.Builder + body.WriteString(embed.Description) + + if embed.Author.Name != "" { + body.WriteString("\n\n**Author**\n") + body.WriteString(embed.Author.Name) + if embed.Author.URL != "" { + body.WriteString(" (" + embed.Author.URL + ")") + } + } + + if embed.Footer.Text != "" { + body.WriteString("\n\n" + embed.Footer.Text) + } + + not.Body = body.String() + + notifications[i] = not + } + + return notifications, nil +} diff --git a/config.example.scfg b/config.example.scfg index 9437d2a..78dff6f 100644 --- a/config.example.scfg +++ b/config.example.scfg @@ -23,3 +23,12 @@ handler "/flux" { # type "alertmanager" # topic "/infra" # } + +# Handle discord type messages. This is meant for +# webhook that doesn't support generic one's. +# Instead, we convert discord messages to ntfy message. +# See: https://discord.com/developers/docs/resources/channel#message-object +handler "/discord-like" { + type "discord_embed" # handle message with `embeds` content + topic "discord-like" +} diff --git a/config/config.go b/config/config.go index 2b832f4..a02188c 100644 --- a/config/config.go +++ b/config/config.go @@ -11,12 +11,15 @@ type HandlerType int const ( HandlerFlux HandlerType = iota + 1 + HandlerDiscordEmbed ) func (h HandlerType) String() string { switch h { case HandlerFlux: return "flux" + case HandlerDiscordEmbed: + return "discord_embed" } panic("unreachable") } @@ -148,6 +151,8 @@ func readHandlerType(d *scfg.Directive) (HandlerType, error) { switch ty { case "flux": return HandlerFlux, nil + case "discord_embed": + return HandlerDiscordEmbed, nil default: return 0, fmt.Errorf("invalid handler type %q", ty) } diff --git a/k8s/bridge.yaml b/k8s/bridge.yaml index 5f393b7..490c21b 100644 --- a/k8s/bridge.yaml +++ b/k8s/bridge.yaml @@ -17,6 +17,11 @@ data: type "flux" topic "flux" } + + handler "/forgejo" { + type "discord_embed" + topic "forgejo" + } --- apiVersion: apps/v1 kind: Deployment diff --git a/main.go b/main.go index 8ea1926..252371e 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,8 @@ func main() { switch handler.Type { case config.HandlerFlux: h = bridge.NewFluxHandler() + case config.HandlerDiscordEmbed: + h = bridge.NewDiscordEmbedHandler() } slog.Debug("Registering bridge", "route", route, "handler", handler.Type) From b73a80b4fa8fca1ce9f9f68019eca73e37bc66b2 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 16:56:16 +0200 Subject: [PATCH 12/23] chore: enable debug in skaffold --- k8s/bridge.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/bridge.yaml b/k8s/bridge.yaml index 490c21b..b9f00b5 100644 --- a/k8s/bridge.yaml +++ b/k8s/bridge.yaml @@ -6,7 +6,7 @@ metadata: data: config.scfg: | http-address 0.0.0.0:8080 - log-level info + log-level debug log-format text ntfy { From 0ef9170f4e46d367c27075c8ef71c0053bb10ba0 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Sun, 3 Sep 2023 18:05:52 +0200 Subject: [PATCH 13/23] chore: configure renovate --- renovate.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index 9a3152d..5c8b953 100644 --- a/renovate.json +++ b/renovate.json @@ -2,6 +2,9 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", - ":dependencyDashboard" + ":dependencyDashboard", + ":automergeMinor", + ":automergePatch", + ":maintainLockFilesWeekly" ] } From a428a61fc39f3b3036c4be8af76f37676552a7ca Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Mon, 11 Sep 2023 17:59:28 +0200 Subject: [PATCH 14/23] chore: update dockerignore to remove direnv --- .dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.dockerignore b/.dockerignore index dae6265..080a232 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ +/.direnv /.git /k8s /flake.* From 1bc747c18b830befd90d066dbd986818676da77c Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Mon, 11 Sep 2023 18:47:25 +0200 Subject: [PATCH 15/23] chore: remove errSkipNotification --- bridge/bridge.go | 15 ++++++--------- bridge/flux.go | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index 29b7b2d..7c350bd 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -10,10 +10,6 @@ import ( "strings" ) -var ( - errSkipNotification = errors.New("notification skipped") -) - type Handler interface { ProduceNotifications(r *http.Request) ([]Notification, error) } @@ -168,17 +164,18 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { nots, err := b.h.ProduceNotifications(r) - if errors.Is(err, errSkipNotification) { - w.WriteHeader(http.StatusNoContent) - return - } - if err != nil { slog.Error("failed to format notification") w.WriteHeader(http.StatusBadRequest) return } + if len(nots) == 0 { + slog.Debug("no notification produced") + w.WriteHeader(http.StatusNoContent) + return + } + for _, not := range nots { not.topic = b.topic not.auth = b.auth diff --git a/bridge/flux.go b/bridge/flux.go index 0088240..8f9bc9f 100644 --- a/bridge/flux.go +++ b/bridge/flux.go @@ -63,7 +63,7 @@ func (f FluxHandler) ProduceNotifications(r *http.Request) ([]Notification, erro if not.Reason == "ReconciliationSucceeded" { if ok := f.reconciliations[obj]; !ok { // Filter out spammy ReconciliationSucceeded notification - return nil, errSkipNotification + return nil, nil } // we will print the object so skip it next time it spam From 773555491a56bbe8e00a3181f0f7d8219e5abe32 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Mon, 11 Sep 2023 18:54:24 +0200 Subject: [PATCH 16/23] feat: add alertmanager handler --- bridge/alertmanager.go | 70 ++++++++++++++++++++++++++++++++++++++++++ config/config.go | 5 +++ k8s/bridge.yaml | 5 +++ main.go | 2 ++ 4 files changed, 82 insertions(+) create mode 100644 bridge/alertmanager.go diff --git a/bridge/alertmanager.go b/bridge/alertmanager.go new file mode 100644 index 0000000..c1fb9a9 --- /dev/null +++ b/bridge/alertmanager.go @@ -0,0 +1,70 @@ +package bridge + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" +) + +type AlertmanagerEvent struct { + Receiver string `json:"receiver"` + Status string `json:"status"` + Alerts []struct { + Status string `json:"status"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + GeneratorURL string `json:"generatorURL"` + Fingerprint string `json:"fingerprint"` + } `json:"alerts"` + GroupLabels map[string]string `json:"groupLabels"` + CommonLabels map[string]string `json:"commonLabels"` + CommonAnnotations map[string]string `json:"commonAnnotations"` + ExternalURL string `json:"externalURL"` + Version string `json:"version"` + GroupKey string `json:"groupKey"` + TruncatedAlerts int `json:"truncatedAlerts"` +} + +type AlertmanagerHandler struct{} + +func NewAlertmanagerHandler() AlertmanagerHandler { + return AlertmanagerHandler{} +} + +func (d AlertmanagerHandler) ProduceNotifications(r *http.Request) ([]Notification, error) { + l := slog.With(slog.String("handler", "alertmanager")) + + dec := json.NewDecoder(r.Body) + defer r.Body.Close() + + var event AlertmanagerEvent + if err := dec.Decode(&event); err != nil { + l.Error("invalid message format", "error", err) + return nil, err + } + + notifications := make([]Notification, 0, len(event.Alerts)) + for _, alert := range event.Alerts { + if alert.Annotations["summary"] == "" { + continue + } + + var not Notification + not.Title = "[" + strings.ToUpper(event.Status) + "] " + alert.Annotations["summary"] + not.Body = alert.Annotations["description"] + if runbook := alert.Annotations["runbook_url"]; runbook != "" { + not.Actions = append(not.Actions, NewViewAction("Runbook", runbook)) + } + if event.Status == "resolved" { + not.Tags = []string{"resolved"} + } + + notifications = append(notifications, not) + } + + return notifications, nil +} diff --git a/config/config.go b/config/config.go index a02188c..9e86006 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ type HandlerType int const ( HandlerFlux HandlerType = iota + 1 HandlerDiscordEmbed + HandlerAlertmanager ) func (h HandlerType) String() string { @@ -20,6 +21,8 @@ func (h HandlerType) String() string { return "flux" case HandlerDiscordEmbed: return "discord_embed" + case HandlerAlertmanager: + return "alertmanager" } panic("unreachable") } @@ -153,6 +156,8 @@ func readHandlerType(d *scfg.Directive) (HandlerType, error) { return HandlerFlux, nil case "discord_embed": return HandlerDiscordEmbed, nil + case "alertmanager": + return HandlerAlertmanager, nil default: return 0, fmt.Errorf("invalid handler type %q", ty) } diff --git a/k8s/bridge.yaml b/k8s/bridge.yaml index b9f00b5..915188d 100644 --- a/k8s/bridge.yaml +++ b/k8s/bridge.yaml @@ -22,6 +22,11 @@ data: type "discord_embed" topic "forgejo" } + + handler "/alerts" { + type "alertmanager" + topic "infra" + } --- apiVersion: apps/v1 kind: Deployment diff --git a/main.go b/main.go index 252371e..0846dd7 100644 --- a/main.go +++ b/main.go @@ -76,6 +76,8 @@ func main() { h = bridge.NewFluxHandler() case config.HandlerDiscordEmbed: h = bridge.NewDiscordEmbedHandler() + case config.HandlerAlertmanager: + h = bridge.NewAlertmanagerHandler() } slog.Debug("Registering bridge", "route", route, "handler", handler.Type) From c1f23220fabb7a0344705069a194b15826b6e3bd Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Mon, 11 Sep 2023 19:58:02 +0200 Subject: [PATCH 17/23] docs: add README instructions --- README.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 54244ce..569830c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ # ntfy-bridge -Bridge for various implementations to publish to ntfy. \ No newline at end of file +Bridge for various implementations to publish to ntfy. + +## Installation + +Using go: + +```sh +go install forge.babariviere.com/babariviere/ntfy-bridge@latest +``` + +Or using docker: + +```sh +docker pull forge.babariviere.com/babariviere/ntfy-bridge:latest +``` + +Binaries are also avaiable in the [release section](https://forge.babariviere.com/babariviere/ntfy-bridge/releases). + +## Usage + +First, you need to create a configuration file. A sample one is provided [here](./config.example.scfg). + +For now, we have these handler types: +- `flux`: handle notification from [Flux](https://fluxcd.io) +- `discord_embed`: handle preformated notification from discord embeds (see [embed object](https://discord.com/developers/docs/resources/channel#embed-object)) +- `alertmanager`: handle notification from alertmanager using [webhook_config](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) + +Once you have created your config file, you can either put it in these directories: +- `/etc/ntfy-bridge/config.scfg` +- `$HOME/.ntfy-bridge/config.scfg` +- `$HOME/.config/ntfy-bridge/config.scfg` +- `config.scfg` (current directory) + +Then, you can simply run the binary with either the native binary: + +```sh +./ntfy-bridge +``` + +Or via docker: + +```sh +docker run -v config.scfg:/etc/ntfy-bridge/config.scfg -p 8080 forge.babariviere.com/babariviere/ntfy-bridge:latest +``` + +Sample config for kubernetes can be found in [./k8s/](./k8s/) directory. From dd38badc6eec33315654782098c619b087450058 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Mon, 11 Sep 2023 20:01:26 +0200 Subject: [PATCH 18/23] style: run formatter --- bridge/bridge.go | 1 - 1 file changed, 1 deletion(-) diff --git a/bridge/bridge.go b/bridge/bridge.go index 7c350bd..0075236 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -163,7 +163,6 @@ func (b Bridge) ServeHTTP(w http.ResponseWriter, r *http.Request) { } nots, err := b.h.ProduceNotifications(r) - if err != nil { slog.Error("failed to format notification") w.WriteHeader(http.StatusBadRequest) From a502442913035605eb1345d8b5a2ff6de7e77de8 Mon Sep 17 00:00:00 2001 From: Bastien Riviere Date: Mon, 11 Sep 2023 20:01:53 +0200 Subject: [PATCH 19/23] chore: bump go to 1.21 in go.mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9fdda8d..ae3f91e 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module forge.babariviere.com/babariviere/ntfy-bridge -go 1.20 +go 1.21 require git.sr.ht/~emersion/go-scfg v0.0.0-20230828131541-76adf4aeafd7 From 45bf45902c40ffbe1f6a7f70ba06760921cc0ea4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 10 Sep 2023 15:01:30 +0000 Subject: [PATCH 20/23] chore(deps): update actions/checkout action to v4 --- .forgejo/workflows/go.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/go.yaml b/.forgejo/workflows/go.yaml index 4927f09..12f9ac3 100644 --- a/.forgejo/workflows/go.yaml +++ b/.forgejo/workflows/go.yaml @@ -14,7 +14,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 From 7384d97afbed1ac07b3b239852639c81ffc12faf Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 6 Dec 2023 16:00:58 +0000 Subject: [PATCH 21/23] chore(deps): update actions/setup-go action to v5 --- .forgejo/workflows/go.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/go.yaml b/.forgejo/workflows/go.yaml index 12f9ac3..2a6065a 100644 --- a/.forgejo/workflows/go.yaml +++ b/.forgejo/workflows/go.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: '1.21' From f8a25d43354492dd065d07826c002c96dcfa4770 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 8 Dec 2023 02:02:26 +0000 Subject: [PATCH 22/23] chore(deps): update alpine docker tag to v3.19 --- release/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/Dockerfile b/release/Dockerfile index 741d5ea..73886c5 100644 --- a/release/Dockerfile +++ b/release/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.18 as alpine +FROM alpine:3.19 as alpine RUN apk add -U --no-cache ca-certificates FROM scratch From 18612c7eff403941c2e92c32483b213aa6b323b1 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 7 Feb 2024 03:02:15 +0000 Subject: [PATCH 23/23] chore(deps): update golang docker tag to v1.22 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cca5468..20ce00b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-bookworm as builder +FROM golang:1.22-bookworm as builder WORKDIR /app