From ba01ad6e8aa1b069d6e38161dbca1413b10978e3 Mon Sep 17 00:00:00 2001 From: Chose Carreras Date: Sun, 16 Aug 2026 09:31:26 +0200 Subject: [PATCH] Add Mastodon alert provider Add the mastodon Provider type for posting Flux events as statuses on a Mastodon account. Events are published with a plain HTTP POST to the /api/v1/statuses endpoint of the server given in the address, using an OAuth access token with the write:statuses scope as a bearer token. The status text carries a severity emoji, the involved object, the event message and the event metadata as key-value lines, truncated to the 500-character default server limit. An Idempotency-Key header derived from the event guards against duplicate statuses when the HTTP client retries a request whose response was lost. An optional visibility query parameter on the address maps to the status visibility field, since the Provider API has no dedicated field for it. Assisted-by: Claude Code/claude-fable-5 Signed-off-by: Chose Carreras --- api/v1beta3/provider_types.go | 3 +- ...ification.toolkit.fluxcd.io_providers.yaml | 1 + docs/spec/v1beta3/providers.md | 58 +++++++ internal/notifier/factory.go | 5 + internal/notifier/mastodon.go | 141 ++++++++++++++++++ internal/notifier/mastodon_test.go | 122 +++++++++++++++ 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 internal/notifier/mastodon.go create mode 100644 internal/notifier/mastodon_test.go diff --git a/api/v1beta3/provider_types.go b/api/v1beta3/provider_types.go index be4e230e3..bf7a4367e 100644 --- a/api/v1beta3/provider_types.go +++ b/api/v1beta3/provider_types.go @@ -59,13 +59,14 @@ const ( OTELProvider string = "otel" ZoomProvider string = "zoom" IncidentioProvider string = "incident.io" + MastodonProvider string = "mastodon" ) // ProviderSpec defines the desired state of the Provider. // +kubebuilder:validation:XValidation:rule="self.type == 'github' || self.type == 'gitlab' || self.type == 'gitea' || self.type == 'bitbucketserver' || self.type == 'bitbucket' || self.type == 'azuredevops' || !has(self.commitStatusExpr)", message="spec.commitStatusExpr is only supported for the 'github', 'gitlab', 'gitea', 'bitbucketserver', 'bitbucket', 'azuredevops' provider types" type ProviderSpec struct { // Type specifies which Provider implementation to use. - // +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;gitea;giteapullrequestcomment;bitbucketserver;bitbucket;azuredevops;googlechat;googlepubsub;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;githubpullrequestcomment;gitlabmergerequestcomment;pagerduty;datadog;nats;zulip;otel;zoom;incident.io + // +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;gitea;giteapullrequestcomment;bitbucketserver;bitbucket;azuredevops;googlechat;googlepubsub;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;githubpullrequestcomment;gitlabmergerequestcomment;pagerduty;datadog;nats;zulip;otel;zoom;incident.io;mastodon // +required Type string `json:"type"` diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml index f17217c69..a1a249ad4 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -188,6 +188,7 @@ spec: - otel - zoom - incident.io + - mastodon type: string username: description: Username specifies the name under which events are posted. diff --git a/docs/spec/v1beta3/providers.md b/docs/spec/v1beta3/providers.md index 57a173655..2bc05b755 100644 --- a/docs/spec/v1beta3/providers.md +++ b/docs/spec/v1beta3/providers.md @@ -97,6 +97,7 @@ The supported alerting providers are: | [Google Pub/Sub](#google-pubsub) | `googlepubsub` | | [Grafana](#grafana) | `grafana` | | [incident.io](#incidentio) | `incident.io` | +| [Mastodon](#mastodon) | `mastodon` | | [Lark](#lark) | `lark` | | [Matrix](#matrix) | `matrix` | | [Microsoft Teams](#microsoft-teams) | `msteams` | @@ -1419,6 +1420,63 @@ stringData: token: ``` +##### Mastodon + +When `.spec.type` is set to `mastodon`, the controller will publish an +[Event](events.md#event-structure) as a +[status](https://docs.joinmastodon.org/methods/statuses/#create) on the +Mastodon account owning the referenced access token. + +The [Address](#address) is the Mastodon server root URL, e.g. +`https://mastodon.social` (the `/api/v1/statuses` path is appended +automatically). An optional `visibility` query parameter (`public`, +`unlisted` or `private`) overrides the app's default status visibility, +e.g. `https://mastodon.social?visibility=unlisted`. + +The status text contains the involved object, the event message and the event +metadata as key-value lines. Statuses longer than 500 characters (the default +Mastodon server limit) are truncated. An `Idempotency-Key` header derived from +the event is sent to prevent duplicate statuses on retried requests. + +The access token must be provided in the `token` key of the referenced Secret, +it is sent as a bearer token in the `Authorization` header of the POST request. +The token can be generated in the Mastodon web interface under +`Preferences → Development → New application` and requires the +`write:statuses` scope. + +This Provider type does support the configuration of a [proxy URL](#https-proxy) +and [certificate secret reference](#certificate-secret-reference). + +###### Mastodon example + +To configure a Provider for Mastodon, create an application with the +`write:statuses` scope in the Mastodon web interface to obtain the access +token, then create a Secret with [the `address`](#address-example) set to the +server root URL, [the `token`](#token-example) set to the access token, and a +`mastodon` Provider with a [Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Provider +metadata: + name: mastodon + namespace: default +spec: + type: mastodon + secretRef: + name: mastodon-app +--- +apiVersion: v1 +kind: Secret +metadata: + name: mastodon-app + namespace: default +stringData: + address: https://mastodon.social + token: +``` + ### Address diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index da00ec976..583633a3e 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -67,6 +67,7 @@ var ( apiv1.OTELProvider: otelNotifierFunc, apiv1.ZoomProvider: zoomNotifierFunc, apiv1.IncidentioProvider: incidentioNotifierFunc, + apiv1.MastodonProvider: mastodonNotifierFunc, } ) @@ -395,3 +396,7 @@ func zoomNotifierFunc(opts notifierOptions) (Interface, error) { func incidentioNotifierFunc(opts notifierOptions) (Interface, error) { return NewIncidentio(opts.URL, opts.ProxyURL, opts.TLSConfig, opts.Token) } + +func mastodonNotifierFunc(opts notifierOptions) (Interface, error) { + return NewMastodon(opts.URL, opts.ProxyURL, opts.TLSConfig, opts.Token) +} diff --git a/internal/notifier/mastodon.go b/internal/notifier/mastodon.go new file mode 100644 index 000000000..1c4956fb1 --- /dev/null +++ b/internal/notifier/mastodon.go @@ -0,0 +1,141 @@ +/* +Copyright 2026 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" + "crypto/sha256" + "crypto/tls" + "errors" + "fmt" + "net/url" + "strings" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/hashicorp/go-retryablehttp" +) + +// mastodonStatusesPath is the endpoint for publishing a status. +// Reference: https://docs.joinmastodon.org/methods/statuses/#create +const mastodonStatusesPath = "/api/v1/statuses" + +// mastodonMaxChars is the default status character limit of a Mastodon +// server. Statuses are truncated to this length as the limit cannot be +// discovered without an extra API call and exceeding it fails the post. +const mastodonMaxChars = 500 + +// Mastodon holds the server URL and OAuth access token +// for posting statuses to a Mastodon account. +type Mastodon struct { + // URL is the fully resolved statuses endpoint of the server. + URL string + ProxyURL string + Token string + Visibility string + TLSConfig *tls.Config +} + +// MastodonPayload is the JSON form accepted by the statuses endpoint. +type MastodonPayload struct { + Status string `json:"status"` + Visibility string `json:"visibility,omitempty"` +} + +// NewMastodon validates the Mastodon server URL and returns a Mastodon +// object. The address may be the server root URL, in which case the +// statuses API path is appended. An optional `visibility` query parameter +// (public, unlisted, private) overrides the app's default status visibility. +func NewMastodon(serverURL string, proxyURL string, tlsConfig *tls.Config, token string) (*Mastodon, error) { + u, err := url.ParseRequestURI(serverURL) + if err != nil { + return nil, fmt.Errorf("invalid Mastodon server URL %s: '%w'", serverURL, err) + } + + if token == "" { + return nil, errors.New("empty Mastodon access token") + } + + // The visibility is carried as a query parameter of the address + // because the Provider API has no dedicated field for it. + q := u.Query() + visibility := q.Get("visibility") + q.Del("visibility") + u.RawQuery = q.Encode() + + if !strings.HasSuffix(strings.TrimSuffix(u.Path, "/"), mastodonStatusesPath) { + u.Path = strings.TrimSuffix(u.Path, "/") + mastodonStatusesPath + } + + return &Mastodon{ + URL: u.String(), + ProxyURL: proxyURL, + Token: token, + Visibility: visibility, + TLSConfig: tlsConfig, + }, nil +} + +// Post the event as a status on the Mastodon account owning the token. +func (m *Mastodon) Post(ctx context.Context, event eventv1.Event) error { + emoji := "💫" + if event.Severity == eventv1.EventSeverityError { + emoji = "🚨" + } + + heading := fmt.Sprintf("%s %s/%s.%s", emoji, strings.ToLower(event.InvolvedObject.Kind), + event.InvolvedObject.Name, event.InvolvedObject.Namespace) + + var metadata strings.Builder + for k, v := range event.Metadata { + metadata.WriteString(fmt.Sprintf("%s: %s\n", k, v)) + } + status := fmt.Sprintf("%s\n%s\n\n%s", heading, event.Message, metadata.String()) + status = strings.TrimSpace(status) + if runes := []rune(status); len(runes) > mastodonMaxChars { + status = string(runes[:mastodonMaxChars-1]) + "…" + } + + payload := MastodonPayload{ + Status: status, + Visibility: m.Visibility, + } + + // The Idempotency-Key header prevents a duplicate status when a retried + // request succeeded but its response was lost. The event timestamp keeps + // the key unique across recurring events of the same object. + idempotencyKey := sha256.Sum256([]byte(fmt.Sprintf("%s/%s/%s/%s", + event.InvolvedObject.UID, event.Reason, event.Timestamp.UTC().String(), status))) + + opts := []postOption{ + withRequestModifier(func(req *retryablehttp.Request) { + req.Header.Set("Authorization", "Bearer "+m.Token) + req.Header.Set("Idempotency-Key", fmt.Sprintf("%x", idempotencyKey)) + }), + } + if m.ProxyURL != "" { + opts = append(opts, withProxy(m.ProxyURL)) + } + if m.TLSConfig != nil { + opts = append(opts, withTLSConfig(m.TLSConfig)) + } + + if err := postMessage(ctx, m.URL, payload, opts...); err != nil { + return fmt.Errorf("postMessage failed: %w", err) + } + + return nil +} diff --git a/internal/notifier/mastodon_test.go b/internal/notifier/mastodon_test.go new file mode 100644 index 000000000..8e9715cf1 --- /dev/null +++ b/internal/notifier/mastodon_test.go @@ -0,0 +1,122 @@ +/* +Copyright 2026 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" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + . "github.com/onsi/gomega" +) + +func TestMastodon_Post(t *testing.T) { + g := NewWithT(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.Expect(r.URL.Path).To(Equal("/api/v1/statuses")) + g.Expect(r.Header.Get("Authorization")).To(Equal("Bearer token")) + g.Expect(r.Header.Get("Idempotency-Key")).ToNot(BeEmpty()) + g.Expect(r.Header.Get("Content-Type")).To(Equal("application/json")) + + b, err := io.ReadAll(r.Body) + g.Expect(err).ToNot(HaveOccurred()) + var payload = MastodonPayload{} + err = json.Unmarshal(b, &payload) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(payload.Status).To(ContainSubstring("💫 gitrepository/webapp.gitops-system")) + g.Expect(payload.Status).To(ContainSubstring("message")) + g.Expect(payload.Status).To(ContainSubstring("test: metadata")) + g.Expect(payload.Visibility).To(BeEmpty()) + })) + defer ts.Close() + + mastodon, err := NewMastodon(ts.URL, "", nil, "token") + g.Expect(err).ToNot(HaveOccurred()) + + err = mastodon.Post(context.TODO(), testEvent()) + g.Expect(err).ToNot(HaveOccurred()) +} + +func TestMastodon_PostVisibilityAndErrorSeverity(t *testing.T) { + g := NewWithT(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.Expect(r.URL.Path).To(Equal("/api/v1/statuses")) + g.Expect(r.URL.Query().Get("visibility")).To(BeEmpty()) + + b, err := io.ReadAll(r.Body) + g.Expect(err).ToNot(HaveOccurred()) + var payload = MastodonPayload{} + err = json.Unmarshal(b, &payload) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(payload.Visibility).To(Equal("unlisted")) + g.Expect(payload.Status).To(ContainSubstring("🚨")) + })) + defer ts.Close() + + mastodon, err := NewMastodon(ts.URL+"?visibility=unlisted", "", nil, "token") + g.Expect(err).ToNot(HaveOccurred()) + + event := testEvent() + event.Severity = "error" + err = mastodon.Post(context.TODO(), event) + g.Expect(err).ToNot(HaveOccurred()) +} + +func TestMastodon_PostStatusTruncated(t *testing.T) { + g := NewWithT(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + g.Expect(err).ToNot(HaveOccurred()) + var payload = MastodonPayload{} + err = json.Unmarshal(b, &payload) + g.Expect(err).ToNot(HaveOccurred()) + + runes := []rune(payload.Status) + g.Expect(len(runes)).To(Equal(mastodonMaxChars)) + g.Expect(runes[len(runes)-1]).To(Equal('…')) + })) + defer ts.Close() + + mastodon, err := NewMastodon(ts.URL, "", nil, "token") + g.Expect(err).ToNot(HaveOccurred()) + + event := testEvent() + event.Message = strings.Repeat("z", 2*mastodonMaxChars) + err = mastodon.Post(context.TODO(), event) + g.Expect(err).ToNot(HaveOccurred()) +} + +func TestNewMastodon(t *testing.T) { + g := NewWithT(t) + + _, err := NewMastodon("invalid-url", "", nil, "token") + g.Expect(err).To(MatchError(ContainSubstring("invalid Mastodon server URL"))) + + _, err = NewMastodon("https://mastodon.social", "", nil, "") + g.Expect(err).To(MatchError(ContainSubstring("empty Mastodon access token"))) + + // The statuses path is preserved when already present in the address. + m, err := NewMastodon("https://mastodon.social/api/v1/statuses", "", nil, "token") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(m.URL).To(Equal("https://mastodon.social/api/v1/statuses")) +}