diff --git a/api/v1beta3/provider_types.go b/api/v1beta3/provider_types.go index 48d4fb7b3..be4bd36d3 100644 --- a/api/v1beta3/provider_types.go +++ b/api/v1beta3/provider_types.go @@ -57,13 +57,14 @@ const ( NATSProvider string = "nats" ZulipProvider string = "zulip" OTELProvider string = "otel" + ZoomProvider string = "zoom" ) // 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 + // +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 // +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 f5df26cf1..42b9a3e90 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -186,6 +186,7 @@ spec: - nats - zulip - otel + - zoom 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 fc2a1988b..a2480f378 100644 --- a/docs/spec/v1beta3/providers.md +++ b/docs/spec/v1beta3/providers.md @@ -110,6 +110,7 @@ The supported alerting providers are: | [NATS](#nats) | `nats` | | [Zulip](#zulip) | `zulip` | | [OTEL](#otel) | `otel` | +| [Zoom](#zoom) | `zoom` | #### Types supporting Git commit status updates @@ -1310,6 +1311,54 @@ stringData: password: ``` +##### Zoom + +When `.spec.type` is set to `zoom`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Zoom Team Chat [Address](#address). + +The Event will be formatted into a [Zoom rich message](https://developers.zoom.us/docs/team-chat/chatbot/customizing-messages/), +with the event severity as sub head and the metadata added as a list of key-value fields. +The controller appends `format=full` to the webhook URL unless a `format` query +parameter is already present. + +The verification token generated by the Zoom Incoming Webhook connection must be +provided in the `token` key of the referenced Secret, it is sent in the +`Authorization` header of the POST request. + +This Provider type does support the configuration of a [proxy URL](#https-proxy) +and [certificate secret reference](#certificate-secret-reference). + +###### Zoom example + +To configure a Provider for Zoom Team Chat, add the +[Incoming Webhook chatbot](https://marketplace.zoom.us/apps/eH_dLuquRd-VYcOsNGy-hQ) +to your Zoom account, create a connection to obtain the endpoint and the +verification token, then create a Secret with [the `address`](#address-example) +set to the endpoint, [the `token`](#token-example) set to the verification token, +and a `zoom` Provider with a [Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Provider +metadata: + name: zoom + namespace: default +spec: + type: zoom + secretRef: + name: zoom-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: zoom-webhook + namespace: default +stringData: + address: https://integrations.zoom.us/chat/webhooks/incomingwebhook/xxxxxxxxxx + token: +``` + ### Address diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index 6b2dfabc0..69a392c8d 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -65,6 +65,7 @@ var ( apiv1.AzureDevOpsProvider: azureDevOpsNotifierFunc, apiv1.ZulipProvider: zulipNotifierFunc, apiv1.OTELProvider: otelNotifierFunc, + apiv1.ZoomProvider: zoomNotifierFunc, } ) @@ -385,3 +386,7 @@ func otelNotifierFunc(opts notifierOptions) (Interface, error) { } return NewOTLPTracer(opts.Context, opts.URL, opts.ProxyURL, opts.Headers, opts.TLSConfig, opts.Username, opts.Token) } + +func zoomNotifierFunc(opts notifierOptions) (Interface, error) { + return NewZoom(opts.URL, opts.ProxyURL, opts.TLSConfig, opts.Token) +} diff --git a/internal/notifier/zoom.go b/internal/notifier/zoom.go new file mode 100644 index 000000000..097614376 --- /dev/null +++ b/internal/notifier/zoom.go @@ -0,0 +1,157 @@ +/* +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/tls" + "errors" + "fmt" + "net/url" + "strings" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/hashicorp/go-retryablehttp" +) + +// Zoom holds the incoming webhook URL and verification token +// for a Zoom Team Chat Incoming Webhook chatbot connection. +type Zoom struct { + URL string + ProxyURL string + Token string + TLSConfig *tls.Config +} + +// ZoomPayload is the rich message format accepted by the +// Incoming Webhook endpoint when called with `?format=full`. +type ZoomPayload struct { + Content ZoomContent `json:"content"` +} + +type ZoomContent struct { + Head ZoomHead `json:"head"` + Body []ZoomBodyItem `json:"body"` +} + +type ZoomHead struct { + Text string `json:"text"` + SubHead *ZoomSubHead `json:"sub_head,omitempty"` +} + +type ZoomSubHead struct { + Text string `json:"text"` +} + +type ZoomBodyItem struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Items []ZoomField `json:"items,omitempty"` +} + +type ZoomField struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// NewZoom validates the Zoom incoming webhook URL and returns a Zoom object +func NewZoom(hookURL string, proxyURL string, tlsConfig *tls.Config, token string) (*Zoom, error) { + _, err := url.ParseRequestURI(hookURL) + if err != nil { + return nil, fmt.Errorf("invalid Zoom incoming webhook URL %s: '%w'", hookURL, err) + } + + if token == "" { + return nil, errors.New("empty Zoom verification token") + } + + return &Zoom{ + URL: hookURL, + ProxyURL: proxyURL, + Token: token, + TLSConfig: tlsConfig, + }, nil +} + +// Post Zoom Team Chat message +func (s *Zoom) Post(ctx context.Context, event eventv1.Event) error { + // Request the rich message format unless the URL already pins one. + u, err := url.ParseRequestURI(s.URL) + if err != nil { + return fmt.Errorf("invalid Zoom incoming webhook URL: %w", err) + } + q := u.Query() + if q.Get("format") == "" { + q.Set("format", "full") + u.RawQuery = q.Encode() + } + + objName := fmt.Sprintf("%s/%s.%s", strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace) + + body := []ZoomBodyItem{ + { + Type: "message", + Text: event.Message, + }, + } + + if len(event.Metadata) > 0 { + fields := make([]ZoomField, 0, len(event.Metadata)) + for k, v := range event.Metadata { + fields = append(fields, ZoomField{ + Key: k, + Value: v, + }) + } + body = append(body, ZoomBodyItem{ + Type: "fields", + Items: fields, + }) + } + + payload := ZoomPayload{ + Content: ZoomContent{ + Head: ZoomHead{ + Text: objName, + SubHead: &ZoomSubHead{ + Text: event.Severity, + }, + }, + Body: body, + }, + } + + opts := []postOption{ + // The Incoming Webhook expects the raw verification token + // in the Authorization header, without a scheme prefix. + withRequestModifier(func(req *retryablehttp.Request) { + req.Header.Set("Authorization", s.Token) + }), + } + if s.ProxyURL != "" { + opts = append(opts, withProxy(s.ProxyURL)) + } + if s.TLSConfig != nil { + opts = append(opts, withTLSConfig(s.TLSConfig)) + } + + if err := postMessage(ctx, u.String(), payload, opts...); err != nil { + return fmt.Errorf("postMessage failed: %w", err) + } + + return nil +} diff --git a/internal/notifier/zoom_test.go b/internal/notifier/zoom_test.go new file mode 100644 index 000000000..133f0fadf --- /dev/null +++ b/internal/notifier/zoom_test.go @@ -0,0 +1,82 @@ +/* +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" + "testing" + + . "github.com/onsi/gomega" +) + +func TestZoom_Post(t *testing.T) { + g := NewWithT(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.Expect(r.URL.Query().Get("format")).To(Equal("full")) + g.Expect(r.Header.Get("Authorization")).To(Equal("token")) + g.Expect(r.Header.Get("Content-Type")).To(Equal("application/json")) + + b, err := io.ReadAll(r.Body) + g.Expect(err).ToNot(HaveOccurred()) + var payload = ZoomPayload{} + err = json.Unmarshal(b, &payload) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(payload.Content.Head.Text).To(Equal("gitrepository/webapp.gitops-system")) + g.Expect(payload.Content.Head.SubHead.Text).To(Equal("info")) + g.Expect(payload.Content.Body[0].Type).To(Equal("message")) + g.Expect(payload.Content.Body[0].Text).To(Equal("message")) + g.Expect(payload.Content.Body[1].Type).To(Equal("fields")) + g.Expect(payload.Content.Body[1].Items[0].Key).To(Equal("test")) + g.Expect(payload.Content.Body[1].Items[0].Value).To(Equal("metadata")) + })) + defer ts.Close() + + zoom, err := NewZoom(ts.URL, "", nil, "token") + g.Expect(err).ToNot(HaveOccurred()) + + err = zoom.Post(context.TODO(), testEvent()) + g.Expect(err).ToNot(HaveOccurred()) +} + +func TestZoom_PostFormatPreserved(t *testing.T) { + g := NewWithT(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.Expect(r.URL.Query().Get("format")).To(Equal("message")) + })) + defer ts.Close() + + zoom, err := NewZoom(ts.URL+"?format=message", "", nil, "token") + g.Expect(err).ToNot(HaveOccurred()) + + err = zoom.Post(context.TODO(), testEvent()) + g.Expect(err).ToNot(HaveOccurred()) +} + +func TestNewZoom(t *testing.T) { + g := NewWithT(t) + + _, err := NewZoom("invalid-url", "", nil, "token") + g.Expect(err).To(MatchError(ContainSubstring("invalid Zoom incoming webhook URL"))) + + _, err = NewZoom("https://integrations.zoom.us/chat/webhooks/incomingwebhook/id", "", nil, "") + g.Expect(err).To(MatchError(ContainSubstring("empty Zoom verification token"))) +}