Skip to content

Commit 98cdff5

Browse files
committed
add ntfy provider
Signed-off-by: Bruno Paz <[email protected]>
1 parent f993350 commit 98cdff5

File tree

5 files changed

+260
-1
lines changed

5 files changed

+260
-1
lines changed

api/v1beta3/provider_types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,13 @@ const (
5252
PagerDutyProvider string = "pagerduty"
5353
DataDogProvider string = "datadog"
5454
NATSProvider string = "nats"
55+
NtfyProvider string = "ntfy"
5556
)
5657

5758
// ProviderSpec defines the desired state of the Provider.
5859
type ProviderSpec struct {
5960
// Type specifies which Provider implementation to use.
60-
// +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;gitea;bitbucketserver;bitbucket;azuredevops;googlechat;googlepubsub;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;pagerduty;datadog;nats
61+
// +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;gitea;bitbucketserver;bitbucket;azuredevops;googlechat;googlepubsub;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;pagerduty;datadog;nats;ntfy
6162
// +required
6263
Type string `json:"type"`
6364

docs/spec/v1beta3/providers.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ The supported alerting providers are:
108108
| [Telegram](#telegram) | `telegram` |
109109
| [WebEx](#webex) | `webex` |
110110
| [NATS](#nats) | `nats` |
111+
| [Ntfy](#ntfy) | `ntfy` |
111112

112113
The supported providers for [Git commit status updates](#git-commit-status-updates) are:
113114

@@ -1018,6 +1019,47 @@ stringData:
10181019
password: <NATS Password>
10191020
```
10201021

1022+
##### Ntfy
1023+
1024+
When `.spec.type` is set to `ntfy`, the controller will publish the payload of
1025+
an [Event](events.md#event-structure) to an [Ntfy topic](https://ntfy.sh/) provided in the
1026+
[Channel](#channel) field, using the server specified in the [Address](#address) field.
1027+
1028+
This Provider type can optionally use the [Secret reference](#secret-reference) to authenticate to the Ntfy server using [Username/Password](https://docs.ntfy.sh/publish/?h=username#username-password).
1029+
The credentials must be specified in [the `username`](#username-example) and `password` fields of the Secret.
1030+
Alternatively, you can also an [Access Token](https://docs.ntfy.sh/publish/?h=username#access-tokens) In this case the `token` should be provided through a
1031+
Secret reference.
1032+
1033+
###### Ntfy with Username/Password Credentials Example
1034+
1035+
To configure a Provider for Ntfy authenticating with Username/Password, create a Secret with the
1036+
`username` and `password` fields set, and add a `ntfy` Provider with the associated
1037+
[Secret reference](#secret-reference).
1038+
1039+
```yaml
1040+
---
1041+
apiVersion: notification.toolkit.fluxcd.io/v1beta3
1042+
kind: Provider
1043+
metadata:
1044+
name: ntfy-provider
1045+
namespace: desired-namespace
1046+
spec:
1047+
type: ntfy
1048+
address: <Ntfy Server URL>
1049+
channel: <Ntfy topic>
1050+
secretRef:
1051+
name: ntfy-provider-creds
1052+
---
1053+
apiVersion: v1
1054+
kind: Secret
1055+
metadata:
1056+
name: ntfy-provider-creds
1057+
namespace: desired-namespace
1058+
stringData:
1059+
username: <Ntfy Username>
1060+
password: <Ntfy Password>
1061+
```
1062+
10211063
### Address
10221064

10231065
`.spec.address` is an optional field that specifies the endpoint where the events are posted.

internal/notifier/factory.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ func (f Factory) Notifier(provider string) (Interface, error) {
119119
n, err = NewDataDog(f.URL, f.ProxyURL, f.CertPool, f.Token)
120120
case apiv1.NATSProvider:
121121
n, err = NewNATS(f.URL, f.Channel, f.Username, f.Password)
122+
case apiv1.NtfyProvider:
123+
n, err = NewNtfy(f.URL, f.Channel, f.Token, f.Username, f.Password)
122124
default:
123125
err = fmt.Errorf("provider %s not supported", provider)
124126
}

internal/notifier/ntfy.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
Copyright 2023 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package notifier
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"net/url"
24+
"strings"
25+
26+
eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1"
27+
"github.com/hashicorp/go-retryablehttp"
28+
)
29+
30+
const (
31+
NtfyTagInfo = "information_source"
32+
NtfyTagError = "rotating_light"
33+
)
34+
35+
type Ntfy struct {
36+
ServerURL string
37+
Topic string
38+
Token string
39+
Username string
40+
Password string
41+
}
42+
43+
type NtfyMessage struct {
44+
Topic string `json:"topic"`
45+
Message string `json:"message"`
46+
Title string `json:"title"`
47+
Tags []string `json:"tags,omitempty"`
48+
}
49+
50+
func NewNtfy(serverURL string, topic string, token string, username string, password string) (*Ntfy, error) {
51+
_, err := url.ParseRequestURI(serverURL)
52+
if err != nil {
53+
return nil, fmt.Errorf("invalid Ntfy server URL %s: '%w'", serverURL, err)
54+
}
55+
56+
if topic == "" {
57+
return nil, errors.New("ntfy topic cannot be empty")
58+
}
59+
60+
return &Ntfy{
61+
ServerURL: serverURL,
62+
Topic: topic,
63+
Token: token,
64+
Username: username,
65+
Password: password,
66+
}, nil
67+
}
68+
69+
func (n *Ntfy) Post(ctx context.Context, event eventv1.Event) error {
70+
71+
// Skip Git commit status update event.
72+
if event.HasMetadata(eventv1.MetaCommitStatusKey, eventv1.MetaCommitStatusUpdateValue) {
73+
return nil
74+
}
75+
76+
tags := make([]string, 0)
77+
78+
switch event.Severity {
79+
case eventv1.EventSeverityInfo:
80+
tags = append(tags, NtfyTagInfo)
81+
case eventv1.EventSeverityError:
82+
tags = append(tags, NtfyTagError)
83+
}
84+
85+
payload := NtfyMessage{
86+
Topic: n.Topic,
87+
Title: fmt.Sprintf("FluxCD: %s", event.ReportingController),
88+
Message: n.buildMessageFromEvent(event),
89+
Tags: tags,
90+
}
91+
92+
err := postMessage(ctx, n.ServerURL, "", nil, payload, func(req *retryablehttp.Request) {
93+
n.addAuthorizationHeader(req)
94+
})
95+
96+
return err
97+
}
98+
99+
func (n *Ntfy) addAuthorizationHeader(req *retryablehttp.Request) {
100+
if n.Username != "" && n.Password != "" {
101+
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", basicAuth(n.Username, n.Password)))
102+
} else if n.Token != "" {
103+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", n.Token))
104+
}
105+
}
106+
107+
func (n *Ntfy) buildMessageFromEvent(event eventv1.Event) string {
108+
var messageBuilder strings.Builder
109+
110+
messageBuilder.WriteString(fmt.Sprintf("%s\n\n", event.Message))
111+
messageBuilder.WriteString(fmt.Sprintf("Object: %s/%s.%s\n", event.InvolvedObject.Namespace, event.InvolvedObject.Name, event.InvolvedObject.Kind))
112+
113+
if event.Metadata != nil {
114+
messageBuilder.WriteString("\nMetadata:\n")
115+
for key, val := range event.Metadata {
116+
messageBuilder.WriteString(fmt.Sprintf("%s: %s\n", key, val))
117+
}
118+
}
119+
120+
return messageBuilder.String()
121+
}

internal/notifier/ntfy_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package notifier
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestNewNtfy(t *testing.T) {
16+
t.Run("default values", func(t *testing.T) {
17+
n, err := NewNtfy("https://ntfy.sh", "my-topic", "token", "user", "pass")
18+
assert.NoError(t, err)
19+
assert.Equal(t, "https://ntfy.sh", n.ServerURL)
20+
assert.Equal(t, "my-topic", n.Topic)
21+
assert.Equal(t, "token", n.Token)
22+
assert.Equal(t, "user", n.Username)
23+
assert.Equal(t, "pass", n.Password)
24+
})
25+
26+
t.Run("invalid URL", func(t *testing.T) {
27+
_, err := NewNtfy("not a url", "my-topic", "", "", "")
28+
assert.Contains(t, err.Error(), "invalid Ntfy server URL")
29+
})
30+
31+
t.Run("empty topic", func(t *testing.T) {
32+
_, err := NewNtfy("https://ntfy.sh", "", "", "", "")
33+
assert.Equal(t, err.Error(), "ntfy topic cannot be empty")
34+
})
35+
}
36+
37+
func TestNtfy_Post(t *testing.T) {
38+
39+
t.Run("success", func(t *testing.T) {
40+
evt := testEvent()
41+
42+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+
b, err := io.ReadAll(r.Body)
44+
require.NoError(t, err)
45+
46+
var payload NtfyMessage
47+
err = json.Unmarshal(b, &payload)
48+
require.NoError(t, err)
49+
50+
assert.Equal(t, "my-topic", payload.Topic)
51+
assert.Equal(t, "FluxCD: source-controller", payload.Title)
52+
assert.Equal(t, []string{NtfyTagInfo}, payload.Tags)
53+
assert.Equal(t, "message\n\nObject: gitops-system/webapp.GitRepository\n\nMetadata:\ntest: metadata\n", payload.Message)
54+
}))
55+
defer ts.Close()
56+
57+
ntfy, err := NewNtfy(ts.URL, "my-topic", "", "", "")
58+
require.NoError(t, err)
59+
60+
err = ntfy.Post(context.Background(), evt)
61+
require.NoError(t, err)
62+
})
63+
64+
t.Run("basic authorization", func(t *testing.T) {
65+
evt := testEvent()
66+
67+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68+
assert.Equal(t, r.Header.Get("Authorization"), "Basic YmFzaWMtdXNlcjpiYXNpYy1wYXNzd29yZA==")
69+
}))
70+
defer ts.Close()
71+
72+
ntfy, err := NewNtfy(ts.URL, "my-topic", "", "basic-user", "basic-password")
73+
require.NoError(t, err)
74+
75+
err = ntfy.Post(context.Background(), evt)
76+
require.NoError(t, err)
77+
})
78+
79+
t.Run("access token", func(t *testing.T) {
80+
evt := testEvent()
81+
82+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+
assert.Equal(t, r.Header.Get("Authorization"), "Bearer access-token")
84+
}))
85+
defer ts.Close()
86+
87+
ntfy, err := NewNtfy(ts.URL, "my-topic", "access-token", "", "")
88+
require.NoError(t, err)
89+
90+
err = ntfy.Post(context.Background(), evt)
91+
require.NoError(t, err)
92+
})
93+
}

0 commit comments

Comments
 (0)