diff --git a/api/v1beta3/provider_types.go b/api/v1beta3/provider_types.go index 89f279440..0d1922a10 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" + MSAdaptiveCardProvider string = "msadaptivecard" ) // 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;msadaptivecard // +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 3490d099c..96cd4a226 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -549,6 +549,7 @@ spec: - pagerduty - datadog - nats + - msadaptivecard type: string username: description: Username specifies the name under which events are posted. diff --git a/internal/notifier/adaptive_card.go b/internal/notifier/adaptive_card.go new file mode 100644 index 000000000..4ef0ec8a5 --- /dev/null +++ b/internal/notifier/adaptive_card.go @@ -0,0 +1,201 @@ +/* +Copyright 2024 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/x509" + "fmt" + "net/url" + "slices" + "strings" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" +) + +// MSAdapativeCard holds the configuration for talking to an endpoint that accepts +// an MS Adaptive Card payload. +type MSAdapativeCard struct { + URL string + ProxyURL string + CertPool *x509.CertPool +} + +// The payload structures below reflect this documentation: +// https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL%2Ctext1#send-adaptive-cards-using-an-incoming-webhook + +// MSAdaptiveCardMessage is the message payload for MS Adaptive Card. +type MSAdaptiveCardMessage struct { + Type string `json:"type"` + Attachments []MSAdaptiveCardAttachment `json:"attachments"` +} + +// MSAdaptiveCardAttachment is the attachment payload for MS Adaptive Card. +type MSAdaptiveCardAttachment struct { + ContentType string `json:"contentType"` + ContentURL *string `json:"contentUrl"` + Content MSAdaptiveCardContent `json:"content"` +} + +// MSAdaptiveCardContent is the content payload for MS Adaptive Card. +type MSAdaptiveCardContent struct { + Schema string `json:"$schema"` + Type string `json:"type"` + Version string `json:"version"` + Body []MSAdaptiveCardBodyElement `json:"body"` +} + +// MSAdaptiveCardBodyElement is the body element payload for MS Adaptive Card. +type MSAdaptiveCardBodyElement struct { + Type string `json:"type"` + + *MSAdaptiveCardContainer `json:",inline"` + *MSAdaptiveCardTextBlock `json:",inline"` + *MSAdaptiveCardFactSet `json:",inline"` +} + +// MSAdaptiveCardContainer is the container body element payload for MS Adaptive Card. +type MSAdaptiveCardContainer struct { + Items []MSAdaptiveCardBodyElement `json:"items,omitempty"` +} + +// MSAdaptiveCardTextBlock is the text block body element payload for MS Adaptive Card. +type MSAdaptiveCardTextBlock struct { + Text string `json:"text,omitempty"` + Size string `json:"size,omitempty"` + Style string `json:"style,omitempty"` + Color string `json:"color,omitempty"` + Wrap bool `json:"wrap,omitempty"` +} + +// MSAdaptiveCardFactSet is the fact set body element payload for MS Adaptive Card. +type MSAdaptiveCardFactSet struct { + Facts []MSAdaptiveCardFact `json:"facts,omitempty"` +} + +// MSAdaptiveCardFact is the fact body element payload for MS Adaptive Card. +type MSAdaptiveCardFact struct { + Title string `json:"title"` + Value string `json:"value"` +} + +// NewMSAdaptiveCard validates the MS Adaptive Card endpoint URL and returns an MSAdaptiveCard object. +func NewMSAdaptiveCard(hookURL string, proxyURL string, certPool *x509.CertPool) (*MSAdapativeCard, error) { + _, err := url.ParseRequestURI(hookURL) + if err != nil { + return nil, fmt.Errorf("invalid MS Adaptive Card endpoint URL %s: '%w'", hookURL, err) + } + + return &MSAdapativeCard{ + URL: hookURL, + ProxyURL: proxyURL, + CertPool: certPool, + }, nil +} + +// Post sends the MS Adaptive Card message. +func (m *MSAdapativeCard) Post(ctx context.Context, event eventv1.Event) error { + // Skip Git commit status update event. + if event.HasMetadata(eventv1.MetaCommitStatusKey, eventv1.MetaCommitStatusUpdateValue) { + return nil + } + + objName := fmt.Sprintf("%s/%s.%s", strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace) + + // Prepare message, add red color to error messages. + message := &MSAdaptiveCardTextBlock{ + Text: event.Message, + Wrap: true, + } + if event.Severity == eventv1.EventSeverityError { + message.Color = "attention" + } + + // Put "summary" first, then sort the rest of the metadata by key. + facts := make([]MSAdaptiveCardFact, 0, len(event.Metadata)) + const summaryKey = "summary" + if summary, ok := event.Metadata[summaryKey]; ok { + facts = append(facts, MSAdaptiveCardFact{ + Title: summaryKey, + Value: summary, + }) + } + metadataFirstIndex := len(facts) + for k, v := range event.Metadata { + if k == summaryKey { + continue + } + facts = append(facts, MSAdaptiveCardFact{ + Title: k, + Value: v, + }) + } + slices.SortFunc(facts[metadataFirstIndex:], func(a, b MSAdaptiveCardFact) int { + return strings.Compare(a.Title, b.Title) + }) + + // The card below was built with help from https://adaptivecards.io/designer using the Microsoft Teams host app. + payload := &MSAdaptiveCardMessage{ + Type: "message", + Attachments: []MSAdaptiveCardAttachment{ + { + ContentType: "application/vnd.microsoft.card.adaptive", + ContentURL: nil, // should always be nil according to the docs referenced on the top of the file + Content: MSAdaptiveCardContent{ + Schema: "http://adaptivecards.io/schemas/adaptive-card.json", + Type: "AdaptiveCard", + Version: "1.5", + Body: []MSAdaptiveCardBodyElement{ + { + Type: "Container", + MSAdaptiveCardContainer: &MSAdaptiveCardContainer{ + Items: []MSAdaptiveCardBodyElement{ + { + Type: "TextBlock", + MSAdaptiveCardTextBlock: &MSAdaptiveCardTextBlock{ + Text: objName, + Size: "medium", + Style: "heading", + Wrap: true, + }, + }, + { + Type: "TextBlock", + MSAdaptiveCardTextBlock: message, + }, + { + Type: "FactSet", + MSAdaptiveCardFactSet: &MSAdaptiveCardFactSet{ + Facts: facts, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + err := postMessage(ctx, m.URL, m.ProxyURL, m.CertPool, payload) + if err != nil { + return fmt.Errorf("postMessage failed: %w", err) + } + + return nil +} diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index ae7b036ef..f76674616 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -54,6 +54,7 @@ var ( apiv1.BitbucketServerProvider: bitbucketServerNotifierFunc, apiv1.BitbucketProvider: bitbucketNotifierFunc, apiv1.AzureDevOpsProvider: azureDevOpsNotifierFunc, + apiv1.MSAdaptiveCardProvider: msadaptivecardNotifierFunc, } ) @@ -243,3 +244,7 @@ func bitbucketNotifierFunc(opts notifierOptions) (Interface, error) { func azureDevOpsNotifierFunc(opts notifierOptions) (Interface, error) { return NewAzureDevOps(opts.ProviderUID, opts.URL, opts.Token, opts.CertPool) } + +func msadaptivecardNotifierFunc(opts notifierOptions) (Interface, error) { + return NewMSAdaptiveCard(opts.URL, opts.ProxyURL, opts.CertPool) +}