Skip to content

Commit ba75c8f

Browse files
authored
Merge pull request #1169 from fluxcd/zulip
Introduce zulip alert provider
2 parents 6c1ef1b + de328fa commit ba75c8f

File tree

10 files changed

+398
-30
lines changed

10 files changed

+398
-30
lines changed

api/v1beta3/provider_types.go

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

5758
// ProviderSpec defines the desired state of the Provider.
5859
// +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"
5960
type ProviderSpec struct {
6061
// Type specifies which Provider implementation to use.
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
62+
// +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;zulip
6263
// +required
6364
Type string `json:"type"`
6465

config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ spec:
385385
- pagerduty
386386
- datadog
387387
- nats
388+
- zulip
388389
type: string
389390
username:
390391
description: Username specifies the name under which events are posted.

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+
| [Zulip](#zulip) | `zulip` |
111112

112113
#### Types supporting Git commit status updates
113114

@@ -1139,6 +1140,46 @@ stringData:
11391140
password: <NATS Password>
11401141
```
11411142

1143+
##### Zulip
1144+
1145+
When `.spec.type` is set to `zulip`, the controller will send a Zulip message
1146+
to the provided [Address](#address). The address is expected to be an HTTP/S URL
1147+
pointing to a Zulip server. The `POST /api/v1/messages` API will be used to
1148+
send the message. See the API docs [here](https://zulip.com/api/send-message).
1149+
1150+
The Zulip *channel* and *topic* must be specified in the [Channel](#channel) field,
1151+
separated by a forward slash (`/`). For example: `general/alerts`. In this case, the
1152+
message will be sent to the `general` channel, under the `alerts` topic.
1153+
1154+
The `zulip` Provider supports the configuration of an [HTTP/S proxy](#https-proxy),
1155+
a [certificate secret reference](#certificate-secret-reference) for configuring TLS,
1156+
and a [secret reference](#secret-reference) for HTTP Basic Authentication.
1157+
1158+
Example of a `zulip` Provider with HTTP Basic Authentication:
1159+
1160+
```yaml
1161+
apiVersion: notification.toolkit.fluxcd.io/v1beta3
1162+
kind: Provider
1163+
metadata:
1164+
name: zulip
1165+
namespace: default
1166+
spec:
1167+
type: zulip
1168+
address: https://my-org.zulipchat.com
1169+
channel: my-channel/my-topic
1170+
secretRef:
1171+
name: zulip-basic-auth
1172+
---
1173+
apiVersion: v1
1174+
kind: Secret
1175+
metadata:
1176+
name: zulip-basic-auth
1177+
namespace: default
1178+
stringData:
1179+
username: [email protected] # the Zulip bot email address
1180+
password: F8KXuAylZOta3L5tjgVm3r1YVruUNGXu # the Zulip bot API key
1181+
```
1182+
11421183
### Address
11431184

11441185
`.spec.address` is an optional field that specifies the endpoint where the events are posted.
@@ -1300,6 +1341,7 @@ The following providers support client certificate authentication:
13001341
| `sentry` | Sentry |
13011342
| `slack` | Slack API |
13021343
| `webex` | Webex messages |
1344+
| `zulip` | Zulip API |
13031345

13041346
Support for client certificate authentication is being expanded to additional providers over time.
13051347

internal/notifier/client.go

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,32 @@ import (
3232
type postOptions struct {
3333
proxy string
3434
tlsConfig *tls.Config
35+
contentType string
36+
username string
37+
password string
3538
requestModifier func(*retryablehttp.Request)
36-
responseValidator func(statusCode int, body []byte) error
39+
responseValidator func(*http.Response) error
3740
}
3841

3942
type postOption func(*postOptions)
4043

41-
func postMessage(ctx context.Context, address string, payload interface{}, opts ...postOption) error {
44+
func postMessage(ctx context.Context, address string, payload any, opts ...postOption) error {
4245
options := &postOptions{
4346
// Default validateResponse function verifies that the response status code is 200, 202 or 201.
44-
responseValidator: func(statusCode int, body []byte) error {
45-
if statusCode == http.StatusOK ||
46-
statusCode == http.StatusAccepted ||
47-
statusCode == http.StatusCreated {
47+
responseValidator: func(resp *http.Response) error {
48+
s := resp.StatusCode
49+
if 200 <= s && s < 300 {
4850
return nil
4951
}
5052

51-
return fmt.Errorf("request failed with status code %d, %s", statusCode, string(body))
53+
err := fmt.Errorf("request failed with status code %d", s)
54+
55+
b, bodyErr := io.ReadAll(resp.Body)
56+
if bodyErr != nil {
57+
return fmt.Errorf("%w: unable to read response body: %w", err, bodyErr)
58+
}
59+
60+
return fmt.Errorf("%w: %s", err, string(b))
5261
},
5362
}
5463

@@ -61,17 +70,31 @@ func postMessage(ctx context.Context, address string, payload interface{}, opts
6170
return err
6271
}
6372

64-
data, err := json.Marshal(payload)
65-
if err != nil {
66-
return fmt.Errorf("marshalling notification payload failed: %w", err)
73+
contentType := options.contentType
74+
var data []byte
75+
switch contentType {
76+
case "":
77+
contentType = "application/json"
78+
var err error
79+
data, err = json.Marshal(payload)
80+
if err != nil {
81+
return fmt.Errorf("marshalling notification payload failed: %w", err)
82+
}
83+
default:
84+
data = payload.([]byte)
6785
}
6886

6987
req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodPost, address, data)
7088
if err != nil {
7189
return fmt.Errorf("failed to create a new request: %w", err)
7290
}
7391

74-
req.Header.Set("Content-Type", "application/json")
92+
req.Header.Set("Content-Type", contentType)
93+
94+
if options.username != "" || options.password != "" {
95+
req.SetBasicAuth(options.username, options.password)
96+
}
97+
7598
if options.requestModifier != nil {
7699
options.requestModifier(req)
77100
}
@@ -82,12 +105,7 @@ func postMessage(ctx context.Context, address string, payload interface{}, opts
82105
}
83106
defer resp.Body.Close()
84107

85-
body, err := io.ReadAll(resp.Body)
86-
if err != nil {
87-
return fmt.Errorf("failed to read response body: %w", err)
88-
}
89-
90-
if err := options.responseValidator(resp.StatusCode, body); err != nil {
108+
if err := options.responseValidator(resp); err != nil {
91109
return fmt.Errorf("request failed: %w", err)
92110
}
93111

@@ -106,13 +124,26 @@ func withTLSConfig(tlsConfig *tls.Config) postOption {
106124
}
107125
}
108126

127+
func withContentType(contentType string) postOption {
128+
return func(opts *postOptions) {
129+
opts.contentType = contentType
130+
}
131+
}
132+
133+
func withBasicAuth(username, password string) postOption {
134+
return func(opts *postOptions) {
135+
opts.username = username
136+
opts.password = password
137+
}
138+
}
139+
109140
func withRequestModifier(reqModifier func(*retryablehttp.Request)) postOption {
110141
return func(opts *postOptions) {
111142
opts.requestModifier = reqModifier
112143
}
113144
}
114145

115-
func withResponseValidator(respValidator func(statusCode int, body []byte) error) postOption {
146+
func withResponseValidator(respValidator func(resp *http.Response) error) postOption {
116147
return func(opts *postOptions) {
117148
opts.responseValidator = respValidator
118149
}

internal/notifier/client_test.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,44 @@ func Test_postSelfSignedCert(t *testing.T) {
8989
g.Expect(err).ToNot(HaveOccurred())
9090
}
9191

92+
func Test_postMessage_contentType(t *testing.T) {
93+
const contentType = "application/x-www-form-urlencoded"
94+
95+
g := NewWithT(t)
96+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97+
g.Expect(r.Header.Get("Content-Type")).To(Equal(contentType))
98+
99+
err := r.ParseForm()
100+
g.Expect(err).ToNot(HaveOccurred())
101+
g.Expect(r.Form.Get("status")).To(Equal("success"))
102+
}))
103+
defer ts.Close()
104+
105+
err := postMessage(context.Background(), ts.URL, []byte("status=success"), withContentType(contentType))
106+
g.Expect(err).ToNot(HaveOccurred())
107+
}
108+
109+
func Test_postMessage_basicAuth(t *testing.T) {
110+
g := NewWithT(t)
111+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
username, password, ok := r.BasicAuth()
113+
g.Expect(ok).To(BeTrue())
114+
g.Expect(username).To(Equal("user"))
115+
g.Expect(password).To(Equal("pass"))
116+
117+
b, err := io.ReadAll(r.Body)
118+
g.Expect(err).ToNot(HaveOccurred())
119+
var payload = make(map[string]string)
120+
err = json.Unmarshal(b, &payload)
121+
g.Expect(err).ToNot(HaveOccurred())
122+
g.Expect(payload["status"]).To(Equal("success"))
123+
}))
124+
defer ts.Close()
125+
126+
err := postMessage(context.Background(), ts.URL, map[string]string{"status": "success"}, withBasicAuth("user", "pass"))
127+
g.Expect(err).ToNot(HaveOccurred())
128+
}
129+
92130
func Test_postMessage_requestModifier(t *testing.T) {
93131
g := NewWithT(t)
94132
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
@@ -114,7 +152,11 @@ func Test_postMessage_responseValidator(t *testing.T) {
114152
err := postMessage(context.Background(), ts.URL, map[string]string{"status": "success"})
115153
g.Expect(err).ToNot(HaveOccurred())
116154

117-
err = postMessage(context.Background(), ts.URL, map[string]string{"status": "success"}, withResponseValidator(func(_ int, body []byte) error {
155+
err = postMessage(context.Background(), ts.URL, map[string]string{"status": "success"}, withResponseValidator(func(resp *http.Response) error {
156+
body, err := io.ReadAll(resp.Body)
157+
if err != nil {
158+
return err
159+
}
118160
if strings.HasPrefix(string(body), "error:") {
119161
return errors.New(string(body))
120162
}

internal/notifier/factory.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ var (
6060
apiv1.BitbucketServerProvider: bitbucketServerNotifierFunc,
6161
apiv1.BitbucketProvider: bitbucketNotifierFunc,
6262
apiv1.AzureDevOpsProvider: azureDevOpsNotifierFunc,
63+
apiv1.ZulipProvider: zulipNotifierFunc,
6364
}
6465
)
6566

@@ -355,3 +356,7 @@ func azureDevOpsNotifierFunc(opts notifierOptions) (Interface, error) {
355356
opts.TLSConfig, opts.ProxyURL, opts.ServiceAccountName, opts.ProviderName,
356357
opts.ProviderNamespace, opts.TokenClient, opts.TokenCache)
357358
}
359+
360+
func zulipNotifierFunc(opts notifierOptions) (Interface, error) {
361+
return NewZulip(opts.URL, opts.Channel, opts.ProxyURL, opts.TLSConfig, opts.Username, opts.Password)
362+
}

internal/notifier/slack.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"crypto/tls"
2222
"encoding/json"
2323
"fmt"
24+
"net/http"
2425
"net/url"
2526
"strings"
2627

@@ -149,14 +150,13 @@ func (s *Slack) Post(ctx context.Context, event eventv1.Event) error {
149150
//
150151
// On the other hand, incoming webhooks return more expressive HTTP status codes.
151152
// See https://api.slack.com/messaging/webhooks#handling_errors.
152-
func validateSlackResponse(_ int, body []byte) error {
153-
type slackResponse struct {
153+
func validateSlackResponse(resp *http.Response) error {
154+
var slackResp struct {
154155
Ok bool `json:"ok"`
155156
Error string `json:"error"`
156157
}
157158

158-
slackResp := slackResponse{}
159-
if err := json.Unmarshal(body, &slackResp); err != nil {
159+
if err := json.NewDecoder(resp.Body).Decode(&slackResp); err != nil {
160160
return fmt.Errorf("unable to unmarshal response body: %w", err)
161161
}
162162

internal/notifier/slack_test.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,19 @@ func TestSlack_PostUpdate(t *testing.T) {
6363

6464
func TestSlack_ValidateResponse(t *testing.T) {
6565
g := NewWithT(t)
66-
body := []byte(`{
66+
67+
resp := httptest.NewRecorder()
68+
resp.Write([]byte(`{
6769
"ok": true
68-
}`)
69-
err := validateSlackResponse(http.StatusOK, body)
70+
}`))
71+
err := validateSlackResponse(resp.Result())
7072
g.Expect(err).ToNot(HaveOccurred())
7173

72-
body = []byte(`{
74+
resp = httptest.NewRecorder()
75+
resp.Write([]byte(`{
7376
"ok": false,
7477
"error": "too_many_attachments"
75-
}`)
76-
err = validateSlackResponse(http.StatusOK, body)
78+
}`))
79+
err = validateSlackResponse(resp.Result())
7780
g.Expect(err).To(MatchError(ContainSubstring("Slack responded with error: too_many_attachments")))
7881
}

0 commit comments

Comments
 (0)