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
3 changes: 2 additions & 1 deletion api/v1beta3/provider_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ spec:
- otel
- zoom
- incident.io
- mastodon
type: string
username:
description: Username specifies the name under which events are posted.
Expand Down
58 changes: 58 additions & 0 deletions docs/spec/v1beta3/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -1419,6 +1420,63 @@ stringData:
token: <incident.io API 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: <Mastodon access token>
```


### Address

Expand Down
5 changes: 5 additions & 0 deletions internal/notifier/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ var (
apiv1.OTELProvider: otelNotifierFunc,
apiv1.ZoomProvider: zoomNotifierFunc,
apiv1.IncidentioProvider: incidentioNotifierFunc,
apiv1.MastodonProvider: mastodonNotifierFunc,
}
)

Expand Down Expand Up @@ -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)
}
141 changes: 141 additions & 0 deletions internal/notifier/mastodon.go
Original file line number Diff line number Diff line change
@@ -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
}
122 changes: 122 additions & 0 deletions internal/notifier/mastodon_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}