From 98cdff5ed975e3b418e9ea4bedb39e5310a9b9f1 Mon Sep 17 00:00:00 2001 From: Bruno Paz <184563+brpaz@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:52:38 +0000 Subject: [PATCH] add ntfy provider Signed-off-by: Bruno Paz <184563+brpaz@users.noreply.github.com> --- api/v1beta3/provider_types.go | 3 +- docs/spec/v1beta3/providers.md | 42 ++++++++++++ internal/notifier/factory.go | 2 + internal/notifier/ntfy.go | 121 +++++++++++++++++++++++++++++++++ internal/notifier/ntfy_test.go | 93 +++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 internal/notifier/ntfy.go create mode 100644 internal/notifier/ntfy_test.go diff --git a/api/v1beta3/provider_types.go b/api/v1beta3/provider_types.go index 23e4acead..8f1bfd6c5 100644 --- a/api/v1beta3/provider_types.go +++ b/api/v1beta3/provider_types.go @@ -52,12 +52,13 @@ const ( PagerDutyProvider string = "pagerduty" DataDogProvider string = "datadog" NATSProvider string = "nats" + NtfyProvider string = "ntfy" ) // ProviderSpec defines the desired state of the Provider. type ProviderSpec struct { // Type specifies which Provider implementation to use. - // +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 + // +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 // +required Type string `json:"type"` diff --git a/docs/spec/v1beta3/providers.md b/docs/spec/v1beta3/providers.md index 4e53c5d3c..34c862a9f 100644 --- a/docs/spec/v1beta3/providers.md +++ b/docs/spec/v1beta3/providers.md @@ -108,6 +108,7 @@ The supported alerting providers are: | [Telegram](#telegram) | `telegram` | | [WebEx](#webex) | `webex` | | [NATS](#nats) | `nats` | +| [Ntfy](#ntfy) | `ntfy` | The supported providers for [Git commit status updates](#git-commit-status-updates) are: @@ -1018,6 +1019,47 @@ stringData: password: ``` +##### Ntfy + +When `.spec.type` is set to `ntfy`, the controller will publish the payload of +an [Event](events.md#event-structure) to an [Ntfy topic](https://ntfy.sh/) provided in the +[Channel](#channel) field, using the server specified in the [Address](#address) field. + +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). +The credentials must be specified in [the `username`](#username-example) and `password` fields of the Secret. +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 +Secret reference. + +###### Ntfy with Username/Password Credentials Example + +To configure a Provider for Ntfy authenticating with Username/Password, create a Secret with the +`username` and `password` fields set, and add a `ntfy` Provider with the associated +[Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Provider +metadata: + name: ntfy-provider + namespace: desired-namespace +spec: + type: ntfy + address: + channel: + secretRef: + name: ntfy-provider-creds +--- +apiVersion: v1 +kind: Secret +metadata: + name: ntfy-provider-creds + namespace: desired-namespace +stringData: + username: + password: +``` + ### Address `.spec.address` is an optional field that specifies the endpoint where the events are posted. diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index 3280951ea..99c65e637 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -119,6 +119,8 @@ func (f Factory) Notifier(provider string) (Interface, error) { n, err = NewDataDog(f.URL, f.ProxyURL, f.CertPool, f.Token) case apiv1.NATSProvider: n, err = NewNATS(f.URL, f.Channel, f.Username, f.Password) + case apiv1.NtfyProvider: + n, err = NewNtfy(f.URL, f.Channel, f.Token, f.Username, f.Password) default: err = fmt.Errorf("provider %s not supported", provider) } diff --git a/internal/notifier/ntfy.go b/internal/notifier/ntfy.go new file mode 100644 index 000000000..530688dff --- /dev/null +++ b/internal/notifier/ntfy.go @@ -0,0 +1,121 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notifier + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/hashicorp/go-retryablehttp" +) + +const ( + NtfyTagInfo = "information_source" + NtfyTagError = "rotating_light" +) + +type Ntfy struct { + ServerURL string + Topic string + Token string + Username string + Password string +} + +type NtfyMessage struct { + Topic string `json:"topic"` + Message string `json:"message"` + Title string `json:"title"` + Tags []string `json:"tags,omitempty"` +} + +func NewNtfy(serverURL string, topic string, token string, username string, password string) (*Ntfy, error) { + _, err := url.ParseRequestURI(serverURL) + if err != nil { + return nil, fmt.Errorf("invalid Ntfy server URL %s: '%w'", serverURL, err) + } + + if topic == "" { + return nil, errors.New("ntfy topic cannot be empty") + } + + return &Ntfy{ + ServerURL: serverURL, + Topic: topic, + Token: token, + Username: username, + Password: password, + }, nil +} + +func (n *Ntfy) Post(ctx context.Context, event eventv1.Event) error { + + // Skip Git commit status update event. + if event.HasMetadata(eventv1.MetaCommitStatusKey, eventv1.MetaCommitStatusUpdateValue) { + return nil + } + + tags := make([]string, 0) + + switch event.Severity { + case eventv1.EventSeverityInfo: + tags = append(tags, NtfyTagInfo) + case eventv1.EventSeverityError: + tags = append(tags, NtfyTagError) + } + + payload := NtfyMessage{ + Topic: n.Topic, + Title: fmt.Sprintf("FluxCD: %s", event.ReportingController), + Message: n.buildMessageFromEvent(event), + Tags: tags, + } + + err := postMessage(ctx, n.ServerURL, "", nil, payload, func(req *retryablehttp.Request) { + n.addAuthorizationHeader(req) + }) + + return err +} + +func (n *Ntfy) addAuthorizationHeader(req *retryablehttp.Request) { + if n.Username != "" && n.Password != "" { + req.Header.Set("Authorization", fmt.Sprintf("Basic %s", basicAuth(n.Username, n.Password))) + } else if n.Token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", n.Token)) + } +} + +func (n *Ntfy) buildMessageFromEvent(event eventv1.Event) string { + var messageBuilder strings.Builder + + messageBuilder.WriteString(fmt.Sprintf("%s\n\n", event.Message)) + messageBuilder.WriteString(fmt.Sprintf("Object: %s/%s.%s\n", event.InvolvedObject.Namespace, event.InvolvedObject.Name, event.InvolvedObject.Kind)) + + if event.Metadata != nil { + messageBuilder.WriteString("\nMetadata:\n") + for key, val := range event.Metadata { + messageBuilder.WriteString(fmt.Sprintf("%s: %s\n", key, val)) + } + } + + return messageBuilder.String() +} diff --git a/internal/notifier/ntfy_test.go b/internal/notifier/ntfy_test.go new file mode 100644 index 000000000..6cefb7141 --- /dev/null +++ b/internal/notifier/ntfy_test.go @@ -0,0 +1,93 @@ +package notifier + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewNtfy(t *testing.T) { + t.Run("default values", func(t *testing.T) { + n, err := NewNtfy("https://ntfy.sh", "my-topic", "token", "user", "pass") + assert.NoError(t, err) + assert.Equal(t, "https://ntfy.sh", n.ServerURL) + assert.Equal(t, "my-topic", n.Topic) + assert.Equal(t, "token", n.Token) + assert.Equal(t, "user", n.Username) + assert.Equal(t, "pass", n.Password) + }) + + t.Run("invalid URL", func(t *testing.T) { + _, err := NewNtfy("not a url", "my-topic", "", "", "") + assert.Contains(t, err.Error(), "invalid Ntfy server URL") + }) + + t.Run("empty topic", func(t *testing.T) { + _, err := NewNtfy("https://ntfy.sh", "", "", "", "") + assert.Equal(t, err.Error(), "ntfy topic cannot be empty") + }) +} + +func TestNtfy_Post(t *testing.T) { + + t.Run("success", func(t *testing.T) { + evt := testEvent() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var payload NtfyMessage + err = json.Unmarshal(b, &payload) + require.NoError(t, err) + + assert.Equal(t, "my-topic", payload.Topic) + assert.Equal(t, "FluxCD: source-controller", payload.Title) + assert.Equal(t, []string{NtfyTagInfo}, payload.Tags) + assert.Equal(t, "message\n\nObject: gitops-system/webapp.GitRepository\n\nMetadata:\ntest: metadata\n", payload.Message) + })) + defer ts.Close() + + ntfy, err := NewNtfy(ts.URL, "my-topic", "", "", "") + require.NoError(t, err) + + err = ntfy.Post(context.Background(), evt) + require.NoError(t, err) + }) + + t.Run("basic authorization", func(t *testing.T) { + evt := testEvent() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("Authorization"), "Basic YmFzaWMtdXNlcjpiYXNpYy1wYXNzd29yZA==") + })) + defer ts.Close() + + ntfy, err := NewNtfy(ts.URL, "my-topic", "", "basic-user", "basic-password") + require.NoError(t, err) + + err = ntfy.Post(context.Background(), evt) + require.NoError(t, err) + }) + + t.Run("access token", func(t *testing.T) { + evt := testEvent() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("Authorization"), "Bearer access-token") + })) + defer ts.Close() + + ntfy, err := NewNtfy(ts.URL, "my-topic", "access-token", "", "") + require.NoError(t, err) + + err = ntfy.Post(context.Background(), evt) + require.NoError(t, err) + }) +}