From 4bf469e61be6afde666e173a0de9cd2659845b4f Mon Sep 17 00:00:00 2001 From: Sunny Date: Thu, 1 Jun 2023 00:12:09 +0000 Subject: [PATCH 1/9] Add Alert and Provider v1beta3 API v1beta3 API for Alert and Provider makes them static objects, removing the status subresource and spec fields that are relevant to dynamic objects with reconcilers. Signed-off-by: Sunny --- Makefile | 1 + PROJECT | 10 + api/v1beta2/alert_types.go | 3 +- api/v1beta2/provider_types.go | 1 - api/v1beta3/alert_types.go | 102 +++ api/v1beta3/doc.go | 20 + api/v1beta3/groupversion_info.go | 33 + api/v1beta3/provider_types.go | 149 ++++ api/v1beta3/zz_generated.deepcopy.go | 215 ++++++ ...notification.toolkit.fluxcd.io_alerts.yaml | 133 +++- ...ification.toolkit.fluxcd.io_providers.yaml | 114 +++- .../samples/notification_v1beta3_alert.yaml | 13 + .../notification_v1beta3_provider.yaml | 34 + docs/api/v1beta3/notification.md | 644 ++++++++++++++++++ internal/notifier/factory.go | 2 +- main.go | 2 + 16 files changed, 1470 insertions(+), 6 deletions(-) create mode 100644 api/v1beta3/alert_types.go create mode 100644 api/v1beta3/doc.go create mode 100644 api/v1beta3/groupversion_info.go create mode 100644 api/v1beta3/provider_types.go create mode 100644 api/v1beta3/zz_generated.deepcopy.go create mode 100644 config/samples/notification_v1beta3_alert.yaml create mode 100644 config/samples/notification_v1beta3_provider.yaml create mode 100644 docs/api/v1beta3/notification.md diff --git a/Makefile b/Makefile index e7a1480fe..b0f265377 100644 --- a/Makefile +++ b/Makefile @@ -88,6 +88,7 @@ manifests: controller-gen # Generate API reference documentation api-docs: gen-crd-api-reference-docs $(GEN_CRD_API_REFERENCE_DOCS) -api-dir=./api/v1beta2 -config=./hack/api-docs/config.json -template-dir=./hack/api-docs/template -out-file=./docs/api/v1beta2/notification.md + $(GEN_CRD_API_REFERENCE_DOCS) -api-dir=./api/v1beta3 -config=./hack/api-docs/config.json -template-dir=./hack/api-docs/template -out-file=./docs/api/v1beta3/notification.md $(GEN_CRD_API_REFERENCE_DOCS) -api-dir=./api/v1 -config=./hack/api-docs/config.json -template-dir=./hack/api-docs/template -out-file=./docs/api/v1/notification.md # Run go mod tidy diff --git a/PROJECT b/PROJECT index fa7a1babe..5bbd85678 100644 --- a/PROJECT +++ b/PROJECT @@ -1,3 +1,7 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html domain: toolkit.fluxcd.io repo: github.com/fluxcd/notification-controller resources: @@ -22,4 +26,10 @@ resources: - group: notification kind: Receiver version: v1beta2 +- group: notification + kind: Provider + version: v1beta3 +- group: notification + kind: Alert + version: v1beta3 version: "2" diff --git a/api/v1beta2/alert_types.go b/api/v1beta2/alert_types.go index f39855acf..2d4628900 100644 --- a/api/v1beta2/alert_types.go +++ b/api/v1beta2/alert_types.go @@ -20,7 +20,7 @@ import ( "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/fluxcd/notification-controller/api/v1" + v1 "github.com/fluxcd/notification-controller/api/v1" ) const ( @@ -89,7 +89,6 @@ type AlertStatus struct { // +genclient // +genclient:Namespaced -// +kubebuilder:storageversion // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" diff --git a/api/v1beta2/provider_types.go b/api/v1beta2/provider_types.go index 069b2d4b2..06541b4fb 100644 --- a/api/v1beta2/provider_types.go +++ b/api/v1beta2/provider_types.go @@ -132,7 +132,6 @@ type ProviderStatus struct { // +genclient // +genclient:Namespaced -// +kubebuilder:storageversion // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" diff --git a/api/v1beta3/alert_types.go b/api/v1beta3/alert_types.go new file mode 100644 index 000000000..b3d4b691f --- /dev/null +++ b/api/v1beta3/alert_types.go @@ -0,0 +1,102 @@ +/* +Copyright 2023 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 v1beta3 + +import ( + "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1 "github.com/fluxcd/notification-controller/api/v1" +) + +const ( + AlertKind string = "Alert" +) + +// AlertSpec defines an alerting rule for events involving a list of objects. +type AlertSpec struct { + // ProviderRef specifies which Provider this Alert should use. + // +required + ProviderRef meta.LocalObjectReference `json:"providerRef"` + + // EventSeverity specifies how to filter events based on severity. + // If set to 'info' no events will be filtered. + // +kubebuilder:validation:Enum=info;error + // +kubebuilder:default:=info + // +optional + EventSeverity string `json:"eventSeverity,omitempty"` + + // EventSources specifies how to filter events based + // on the involved object kind, name and namespace. + // +required + EventSources []v1.CrossNamespaceObjectReference `json:"eventSources"` + + // InclusionList specifies a list of Golang regular expressions + // to be used for including messages. + // +optional + InclusionList []string `json:"inclusionList,omitempty"` + + // EventMetadata is an optional field for adding metadata to events dispatched by the + // controller. This can be used for enhancing the context of the event. If a field + // would override one already present on the original event as generated by the emitter, + // then the override doesn't happen, i.e. the original value is preserved, and an info + // log is printed. + // +optional + EventMetadata map[string]string `json:"eventMetadata,omitempty"` + + // ExclusionList specifies a list of Golang regular expressions + // to be used for excluding messages. + // +optional + ExclusionList []string `json:"exclusionList,omitempty"` + + // Summary holds a short description of the impact and affected cluster. + // +kubebuilder:validation:MaxLength:=255 + // +optional + Summary string `json:"summary,omitempty"` + + // Suspend tells the controller to suspend subsequent + // events handling for this Alert. + // +optional + Suspend bool `json:"suspend,omitempty"` +} + +// +genclient +// +genclient:Namespaced +// +kubebuilder:storageversion +// +kubebuilder:object:root=true +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" + +// Alert is the Schema for the alerts API +type Alert struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AlertSpec `json:"spec,omitempty"` +} + +//+kubebuilder:object:root=true + +// AlertList contains a list of Alert +type AlertList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Alert `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Alert{}, &AlertList{}) +} diff --git a/api/v1beta3/doc.go b/api/v1beta3/doc.go new file mode 100644 index 000000000..a6b71b4e5 --- /dev/null +++ b/api/v1beta3/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2023 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 v1beta3 contains API Schema definitions for the notification v1beta3 API group. +// +kubebuilder:object:generate=true +// +groupName=notification.toolkit.fluxcd.io +package v1beta3 diff --git a/api/v1beta3/groupversion_info.go b/api/v1beta3/groupversion_info.go new file mode 100644 index 000000000..233c30e1d --- /dev/null +++ b/api/v1beta3/groupversion_info.go @@ -0,0 +1,33 @@ +/* +Copyright 2023 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 v1beta3 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "notification.toolkit.fluxcd.io", Version: "v1beta3"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1beta3/provider_types.go b/api/v1beta3/provider_types.go new file mode 100644 index 000000000..8bf360f78 --- /dev/null +++ b/api/v1beta3/provider_types.go @@ -0,0 +1,149 @@ +/* +Copyright 2023 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 v1beta3 + +import ( + "time" + + "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ProviderKind string = "Provider" + GenericProvider string = "generic" + GenericHMACProvider string = "generic-hmac" + SlackProvider string = "slack" + GrafanaProvider string = "grafana" + DiscordProvider string = "discord" + MSTeamsProvider string = "msteams" + RocketProvider string = "rocket" + GitHubDispatchProvider string = "githubdispatch" + GitHubProvider string = "github" + GitLabProvider string = "gitlab" + GiteaProvider string = "gitea" + BitbucketServerProvider string = "bitbucketserver" + BitbucketProvider string = "bitbucket" + AzureDevOpsProvider string = "azuredevops" + GoogleChatProvider string = "googlechat" + GooglePubSubProvider string = "googlepubsub" + WebexProvider string = "webex" + SentryProvider string = "sentry" + AzureEventHubProvider string = "azureeventhub" + TelegramProvider string = "telegram" + LarkProvider string = "lark" + Matrix string = "matrix" + OpsgenieProvider string = "opsgenie" + AlertManagerProvider string = "alertmanager" + PagerDutyProvider string = "pagerduty" + DataDogProvider string = "datadog" +) + +// 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 + // +required + Type string `json:"type"` + + // Channel specifies the destination channel where events should be posted. + // +kubebuilder:validation:MaxLength:=2048 + // +optional + Channel string `json:"channel,omitempty"` + + // Username specifies the name under which events are posted. + // +kubebuilder:validation:MaxLength:=2048 + // +optional + Username string `json:"username,omitempty"` + + // Address specifies the endpoint, in a generic sense, to where alerts are sent. + // What kind of endpoint depends on the specific Provider type being used. + // For the generic Provider, for example, this is an HTTP/S address. + // For other Provider types this could be a project ID or a namespace. + // +kubebuilder:validation:MaxLength:=2048 + // +kubebuilder:validation:Optional + // +optional + Address string `json:"address,omitempty"` + + // Timeout for sending alerts to the Provider. + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m))+$" + // +optional + Timeout *metav1.Duration `json:"timeout,omitempty"` + + // Proxy the HTTP/S address of the proxy server. + // +kubebuilder:validation:Pattern="^(http|https)://.*$" + // +kubebuilder:validation:MaxLength:=2048 + // +kubebuilder:validation:Optional + // +optional + Proxy string `json:"proxy,omitempty"` + + // SecretRef specifies the Secret containing the authentication + // credentials for this Provider. + // +optional + SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"` + + // CertSecretRef specifies the Secret containing + // a PEM-encoded CA certificate (in the `ca.crt` key). + // +optional + // + // Note: Support for the `caFile` key has + // been deprecated. + CertSecretRef *meta.LocalObjectReference `json:"certSecretRef,omitempty"` + + // Suspend tells the controller to suspend subsequent + // events handling for this Provider. + // +optional + Suspend bool `json:"suspend,omitempty"` +} + +// +genclient +// +genclient:Namespaced +// +kubebuilder:storageversion +// +kubebuilder:object:root=true +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" + +// Provider is the Schema for the providers API +type Provider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ProviderSpec `json:"spec,omitempty"` +} + +//+kubebuilder:object:root=true + +// ProviderList contains a list of Provider +type ProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Provider `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Provider{}, &ProviderList{}) +} + +// GetTimeout returns the timeout value with a default of 15s for this Provider. +func (in *Provider) GetTimeout() time.Duration { + duration := 15 * time.Second + if in.Spec.Timeout != nil { + duration = in.Spec.Timeout.Duration + } + + return duration +} diff --git a/api/v1beta3/zz_generated.deepcopy.go b/api/v1beta3/zz_generated.deepcopy.go new file mode 100644 index 000000000..8fe59857a --- /dev/null +++ b/api/v1beta3/zz_generated.deepcopy.go @@ -0,0 +1,215 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2023 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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta3 + +import ( + "github.com/fluxcd/notification-controller/api/v1" + "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Alert) DeepCopyInto(out *Alert) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Alert. +func (in *Alert) DeepCopy() *Alert { + if in == nil { + return nil + } + out := new(Alert) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Alert) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AlertList) DeepCopyInto(out *AlertList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Alert, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertList. +func (in *AlertList) DeepCopy() *AlertList { + if in == nil { + return nil + } + out := new(AlertList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AlertList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AlertSpec) DeepCopyInto(out *AlertSpec) { + *out = *in + out.ProviderRef = in.ProviderRef + if in.EventSources != nil { + in, out := &in.EventSources, &out.EventSources + *out = make([]v1.CrossNamespaceObjectReference, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.InclusionList != nil { + in, out := &in.InclusionList, &out.InclusionList + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.EventMetadata != nil { + in, out := &in.EventMetadata, &out.EventMetadata + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ExclusionList != nil { + in, out := &in.ExclusionList, &out.ExclusionList + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertSpec. +func (in *AlertSpec) DeepCopy() *AlertSpec { + if in == nil { + return nil + } + out := new(AlertSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Provider) DeepCopyInto(out *Provider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Provider. +func (in *Provider) DeepCopy() *Provider { + if in == nil { + return nil + } + out := new(Provider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Provider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderList) DeepCopyInto(out *ProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Provider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderList. +func (in *ProviderList) DeepCopy() *ProviderList { + if in == nil { + return nil + } + out := new(ProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec) { + *out = *in + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(metav1.Duration) + **out = **in + } + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(meta.LocalObjectReference) + **out = **in + } + if in.CertSecretRef != nil { + in, out := &in.CertSecretRef, &out.CertSecretRef + *out = new(meta.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderSpec. +func (in *ProviderSpec) DeepCopy() *ProviderSpec { + if in == nil { + return nil + } + out := new(ProviderSpec) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml index 9a97d35d7..30683ac2d 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml @@ -427,6 +427,137 @@ spec: type: object type: object served: true - storage: true + storage: false subresources: status: {} + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta3 + schema: + openAPIV3Schema: + description: Alert is the Schema for the alerts API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: AlertSpec defines an alerting rule for events involving a + list of objects. + properties: + eventMetadata: + additionalProperties: + type: string + description: EventMetadata is an optional field for adding metadata + to events dispatched by the controller. This can be used for enhancing + the context of the event. If a field would override one already + present on the original event as generated by the emitter, then + the override doesn't happen, i.e. the original value is preserved, + and an info log is printed. + type: object + eventSeverity: + default: info + description: EventSeverity specifies how to filter events based on + severity. If set to 'info' no events will be filtered. + enum: + - info + - error + type: string + eventSources: + description: EventSources specifies how to filter events based on + the involved object kind, name and namespace. + items: + description: CrossNamespaceObjectReference contains enough information + to let you locate the typed referenced object at cluster level + properties: + apiVersion: + description: API version of the referent + type: string + kind: + description: Kind of the referent + enum: + - Bucket + - GitRepository + - Kustomization + - HelmRelease + - HelmChart + - HelmRepository + - ImageRepository + - ImagePolicy + - ImageUpdateAutomation + - OCIRepository + type: string + matchLabels: + additionalProperties: + type: string + description: MatchLabels is a map of {key,value} pairs. A single + {key,value} in the matchLabels map is equivalent to an element + of matchExpressions, whose key field is "key", the operator + is "In", and the values array contains only "value". The requirements + are ANDed. MatchLabels requires the name to be set to `*`. + type: object + name: + description: Name of the referent If multiple resources are + targeted `*` may be set. + maxLength: 53 + minLength: 1 + type: string + namespace: + description: Namespace of the referent + maxLength: 53 + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + exclusionList: + description: ExclusionList specifies a list of Golang regular expressions + to be used for excluding messages. + items: + type: string + type: array + inclusionList: + description: InclusionList specifies a list of Golang regular expressions + to be used for including messages. + items: + type: string + type: array + providerRef: + description: ProviderRef specifies which Provider this Alert should + use. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + summary: + description: Summary holds a short description of the impact and affected + cluster. + maxLength: 255 + type: string + suspend: + description: Suspend tells the controller to suspend subsequent events + handling for this Alert. + type: boolean + required: + - eventSources + - providerRef + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml index 032076c16..9fc667e56 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -402,6 +402,118 @@ spec: type: object type: object served: true - storage: true + storage: false subresources: status: {} + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta3 + schema: + openAPIV3Schema: + description: Provider is the Schema for the providers API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: ProviderSpec defines the desired state of the Provider. + properties: + address: + description: Address specifies the endpoint, in a generic sense, to + where alerts are sent. What kind of endpoint depends on the specific + Provider type being used. For the generic Provider, for example, + this is an HTTP/S address. For other Provider types this could be + a project ID or a namespace. + maxLength: 2048 + type: string + certSecretRef: + description: "CertSecretRef specifies the Secret containing a PEM-encoded + CA certificate (in the `ca.crt` key). \n Note: Support for the `caFile` + key has been deprecated." + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + channel: + description: Channel specifies the destination channel where events + should be posted. + maxLength: 2048 + type: string + proxy: + description: Proxy the HTTP/S address of the proxy server. + maxLength: 2048 + pattern: ^(http|https)://.*$ + type: string + secretRef: + description: SecretRef specifies the Secret containing the authentication + credentials for this Provider. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + suspend: + description: Suspend tells the controller to suspend subsequent events + handling for this Provider. + type: boolean + timeout: + description: Timeout for sending alerts to the Provider. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + type: + description: Type specifies which Provider implementation to use. + 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 + type: string + username: + description: Username specifies the name under which events are posted. + maxLength: 2048 + type: string + required: + - type + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/config/samples/notification_v1beta3_alert.yaml b/config/samples/notification_v1beta3_alert.yaml new file mode 100644 index 000000000..2327bf051 --- /dev/null +++ b/config/samples/notification_v1beta3_alert.yaml @@ -0,0 +1,13 @@ +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Alert +metadata: + name: alert-sample +spec: + providerRef: + name: slack-provider-sample + eventSeverity: info + eventSources: + - kind: GitRepository + name: '*' + - kind: Kustomization + name: '*' diff --git a/config/samples/notification_v1beta3_provider.yaml b/config/samples/notification_v1beta3_provider.yaml new file mode 100644 index 000000000..0a03795e7 --- /dev/null +++ b/config/samples/notification_v1beta3_provider.yaml @@ -0,0 +1,34 @@ +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Provider +metadata: + name: slack-provider-sample +spec: + type: slack + channel: general + secretRef: + name: slack-url +--- +apiVersion: v1 +kind: Secret +metadata: + name: slack-url +data: + address: aHR0cHM6Ly9ob29rcy5zbGFjay5jb20vc2VydmljZXMv +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Provider +metadata: + name: generic-provider-sample +spec: + type: generic + address: https://api.github.com/repos/fluxcd/notification-controller/dispatches + secretRef: + name: generic-secret +--- +apiVersion: v1 +kind: Secret +metadata: + name: generic-secret +stringData: + headers: | + Authorization: token diff --git a/docs/api/v1beta3/notification.md b/docs/api/v1beta3/notification.md new file mode 100644 index 000000000..2534fe2e2 --- /dev/null +++ b/docs/api/v1beta3/notification.md @@ -0,0 +1,644 @@ +

Notification API reference v1beta3

+

Packages:

+ +

notification.toolkit.fluxcd.io/v1beta3

+

Package v1beta3 contains API Schema definitions for the notification v1beta3 API group.

+Resource Types: + +

Alert +

+

Alert is the Schema for the alerts API

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+apiVersion
+string
+notification.toolkit.fluxcd.io/v1beta3 +
+kind
+string +
+Alert +
+metadata
+ + +Kubernetes meta/v1.ObjectMeta + + +
+Refer to the Kubernetes API documentation for the fields of the +metadata field. +
+spec
+ + +AlertSpec + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+providerRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+

ProviderRef specifies which Provider this Alert should use.

+
+eventSeverity
+ +string + +
+(Optional) +

EventSeverity specifies how to filter events based on severity. +If set to ‘info’ no events will be filtered.

+
+eventSources
+ + +[]github.com/fluxcd/notification-controller/api/v1.CrossNamespaceObjectReference + + +
+

EventSources specifies how to filter events based +on the involved object kind, name and namespace.

+
+inclusionList
+ +[]string + +
+(Optional) +

InclusionList specifies a list of Golang regular expressions +to be used for including messages.

+
+eventMetadata
+ +map[string]string + +
+(Optional) +

EventMetadata is an optional field for adding metadata to events dispatched by the +controller. This can be used for enhancing the context of the event. If a field +would override one already present on the original event as generated by the emitter, +then the override doesn’t happen, i.e. the original value is preserved, and an info +log is printed.

+
+exclusionList
+ +[]string + +
+(Optional) +

ExclusionList specifies a list of Golang regular expressions +to be used for excluding messages.

+
+summary
+ +string + +
+(Optional) +

Summary holds a short description of the impact and affected cluster.

+
+suspend
+ +bool + +
+(Optional) +

Suspend tells the controller to suspend subsequent +events handling for this Alert.

+
+
+
+
+

Provider +

+

Provider is the Schema for the providers API

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+apiVersion
+string
+notification.toolkit.fluxcd.io/v1beta3 +
+kind
+string +
+Provider +
+metadata
+ + +Kubernetes meta/v1.ObjectMeta + + +
+Refer to the Kubernetes API documentation for the fields of the +metadata field. +
+spec
+ + +ProviderSpec + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+type
+ +string + +
+

Type specifies which Provider implementation to use.

+
+channel
+ +string + +
+(Optional) +

Channel specifies the destination channel where events should be posted.

+
+username
+ +string + +
+(Optional) +

Username specifies the name under which events are posted.

+
+address
+ +string + +
+(Optional) +

Address specifies the endpoint, in a generic sense, to where alerts are sent. +What kind of endpoint depends on the specific Provider type being used. +For the generic Provider, for example, this is an HTTP/S address. +For other Provider types this could be a project ID or a namespace.

+
+timeout
+ + +Kubernetes meta/v1.Duration + + +
+(Optional) +

Timeout for sending alerts to the Provider.

+
+proxy
+ +string + +
+(Optional) +

Proxy the HTTP/S address of the proxy server.

+
+secretRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+(Optional) +

SecretRef specifies the Secret containing the authentication +credentials for this Provider.

+
+certSecretRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+(Optional) +

CertSecretRef specifies the Secret containing +a PEM-encoded CA certificate (in the ca.crt key).

+

Note: Support for the caFile key has +been deprecated.

+
+suspend
+ +bool + +
+(Optional) +

Suspend tells the controller to suspend subsequent +events handling for this Provider.

+
+
+
+
+

AlertSpec +

+

+(Appears on: +Alert) +

+

AlertSpec defines an alerting rule for events involving a list of objects.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+providerRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+

ProviderRef specifies which Provider this Alert should use.

+
+eventSeverity
+ +string + +
+(Optional) +

EventSeverity specifies how to filter events based on severity. +If set to ‘info’ no events will be filtered.

+
+eventSources
+ + +[]github.com/fluxcd/notification-controller/api/v1.CrossNamespaceObjectReference + + +
+

EventSources specifies how to filter events based +on the involved object kind, name and namespace.

+
+inclusionList
+ +[]string + +
+(Optional) +

InclusionList specifies a list of Golang regular expressions +to be used for including messages.

+
+eventMetadata
+ +map[string]string + +
+(Optional) +

EventMetadata is an optional field for adding metadata to events dispatched by the +controller. This can be used for enhancing the context of the event. If a field +would override one already present on the original event as generated by the emitter, +then the override doesn’t happen, i.e. the original value is preserved, and an info +log is printed.

+
+exclusionList
+ +[]string + +
+(Optional) +

ExclusionList specifies a list of Golang regular expressions +to be used for excluding messages.

+
+summary
+ +string + +
+(Optional) +

Summary holds a short description of the impact and affected cluster.

+
+suspend
+ +bool + +
+(Optional) +

Suspend tells the controller to suspend subsequent +events handling for this Alert.

+
+
+
+

ProviderSpec +

+

+(Appears on: +Provider) +

+

ProviderSpec defines the desired state of the Provider.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+type
+ +string + +
+

Type specifies which Provider implementation to use.

+
+channel
+ +string + +
+(Optional) +

Channel specifies the destination channel where events should be posted.

+
+username
+ +string + +
+(Optional) +

Username specifies the name under which events are posted.

+
+address
+ +string + +
+(Optional) +

Address specifies the endpoint, in a generic sense, to where alerts are sent. +What kind of endpoint depends on the specific Provider type being used. +For the generic Provider, for example, this is an HTTP/S address. +For other Provider types this could be a project ID or a namespace.

+
+timeout
+ + +Kubernetes meta/v1.Duration + + +
+(Optional) +

Timeout for sending alerts to the Provider.

+
+proxy
+ +string + +
+(Optional) +

Proxy the HTTP/S address of the proxy server.

+
+secretRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+(Optional) +

SecretRef specifies the Secret containing the authentication +credentials for this Provider.

+
+certSecretRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+(Optional) +

CertSecretRef specifies the Secret containing +a PEM-encoded CA certificate (in the ca.crt key).

+

Note: Support for the caFile key has +been deprecated.

+
+suspend
+ +bool + +
+(Optional) +

Suspend tells the controller to suspend subsequent +events handling for this Provider.

+
+
+
+
+

This page was automatically generated with gen-crd-api-reference-docs

+
diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index 14305b782..4d9900606 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -20,7 +20,7 @@ import ( "crypto/x509" "fmt" - apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta3" ) type Factory struct { diff --git a/main.go b/main.go index 7af53bc83..75f949534 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,7 @@ import ( apiv1 "github.com/fluxcd/notification-controller/api/v1" apiv1b2 "github.com/fluxcd/notification-controller/api/v1beta2" + apiv1b3 "github.com/fluxcd/notification-controller/api/v1beta3" "github.com/fluxcd/notification-controller/internal/controller" "github.com/fluxcd/notification-controller/internal/features" "github.com/fluxcd/notification-controller/internal/server" @@ -67,6 +68,7 @@ func init() { _ = apiv1.AddToScheme(scheme) _ = apiv1b2.AddToScheme(scheme) + _ = apiv1b3.AddToScheme(scheme) // +kubebuilder:scaffold:scheme } From 36bc6529ae613285859405d788666a71bdaaf1dc Mon Sep 17 00:00:00 2001 From: Sunny Date: Thu, 1 Jun 2023 00:16:02 +0000 Subject: [PATCH 2/9] Remove Alert and Provider reconcilers In v1beta3 API, Alert and Provider are static objects and don't need reconcilers. Signed-off-by: Sunny --- config/rbac/role.yaml | 27 - internal/controller/alert_controller.go | 264 --------- internal/controller/alert_controller_test.go | 548 ------------------ internal/controller/provider_controller.go | 334 ----------- .../controller/provider_controller_test.go | 336 ----------- internal/controller/suite_test.go | 22 - internal/controller/testdata/certs/Makefile | 20 - .../controller/testdata/certs/ca-config.json | 13 - .../controller/testdata/certs/ca-csr.json | 9 - internal/controller/testdata/certs/ca-key.pem | 5 - internal/controller/testdata/certs/ca.csr | 9 - internal/controller/testdata/certs/ca.pem | 11 - main.go | 22 - 13 files changed, 1620 deletions(-) delete mode 100644 internal/controller/alert_controller.go delete mode 100644 internal/controller/alert_controller_test.go delete mode 100644 internal/controller/provider_controller.go delete mode 100644 internal/controller/provider_controller_test.go delete mode 100644 internal/controller/testdata/certs/Makefile delete mode 100644 internal/controller/testdata/certs/ca-config.json delete mode 100644 internal/controller/testdata/certs/ca-csr.json delete mode 100644 internal/controller/testdata/certs/ca-key.pem delete mode 100644 internal/controller/testdata/certs/ca.csr delete mode 100644 internal/controller/testdata/certs/ca.pem diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 0511c55ac..ff6c89722 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -40,41 +40,14 @@ rules: resources: - alerts verbs: - - create - - delete - get - list - - patch - - update - - watch -- apiGroups: - - notification.toolkit.fluxcd.io - resources: - - alerts/status - verbs: - - get - - patch - - update - apiGroups: - notification.toolkit.fluxcd.io resources: - providers verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - notification.toolkit.fluxcd.io - resources: - - providers/status - verbs: - get - - patch - - update - apiGroups: - notification.toolkit.fluxcd.io resources: diff --git a/internal/controller/alert_controller.go b/internal/controller/alert_controller.go deleted file mode 100644 index e0afbe21b..000000000 --- a/internal/controller/alert_controller.go +++ /dev/null @@ -1,264 +0,0 @@ -/* -Copyright 2022 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 controller - -import ( - "context" - "fmt" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - kerrors "k8s.io/apimachinery/pkg/util/errors" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/ratelimiter" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/runtime/conditions" - helper "github.com/fluxcd/pkg/runtime/controller" - "github.com/fluxcd/pkg/runtime/patch" - "github.com/fluxcd/pkg/runtime/predicates" - kuberecorder "k8s.io/client-go/tools/record" - - apiv1 "github.com/fluxcd/notification-controller/api/v1" - apiv1beta2 "github.com/fluxcd/notification-controller/api/v1beta2" -) - -var ( - ProviderIndexKey = ".metadata.provider" -) - -// AlertReconciler reconciles a Alert object -type AlertReconciler struct { - client.Client - helper.Metrics - kuberecorder.EventRecorder - - ControllerName string -} - -type AlertReconcilerOptions struct { - RateLimiter ratelimiter.RateLimiter -} - -func (r *AlertReconciler) SetupWithManager(mgr ctrl.Manager) error { - return r.SetupWithManagerAndOptions(mgr, AlertReconcilerOptions{}) -} - -func (r *AlertReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts AlertReconcilerOptions) error { - if err := mgr.GetFieldIndexer().IndexField(context.TODO(), &apiv1beta2.Alert{}, ProviderIndexKey, - func(o client.Object) []string { - alert := o.(*apiv1beta2.Alert) - return []string{ - fmt.Sprintf("%s/%s", alert.GetNamespace(), alert.Spec.ProviderRef.Name), - } - }); err != nil { - return err - } - - return ctrl.NewControllerManagedBy(mgr). - For(&apiv1beta2.Alert{}, builder.WithPredicates( - predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}), - )). - Watches( - &apiv1beta2.Provider{}, - handler.EnqueueRequestsFromMapFunc(r.requestsForProviderChange), - builder.WithPredicates(predicate.GenerationChangedPredicate{}), - ). - WithOptions(controller.Options{ - RateLimiter: opts.RateLimiter, - }). - Complete(r) -} - -// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=alerts,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=alerts/status,verbs=get;update;patch -// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch - -func (r *AlertReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { - reconcileStart := time.Now() - log := ctrl.LoggerFrom(ctx) - - obj := &apiv1beta2.Alert{} - if err := r.Get(ctx, req.NamespacedName, obj); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - - // Initialize the runtime patcher with the current version of the object. - patcher := patch.NewSerialPatcher(obj, r.Client) - - defer func() { - // Patch finalizers, status and conditions. - if err := r.patch(ctx, obj, patcher); err != nil { - retErr = kerrors.NewAggregate([]error{retErr, err}) - } - - // Record Prometheus metrics. - r.Metrics.RecordReadiness(ctx, obj) - r.Metrics.RecordDuration(ctx, obj, reconcileStart) - r.Metrics.RecordSuspend(ctx, obj, obj.Spec.Suspend) - - // Emit warning event if the reconciliation failed. - if retErr != nil { - r.Event(obj, corev1.EventTypeWarning, meta.FailedReason, retErr.Error()) - } - - // Log and emit success event. - if retErr == nil && conditions.IsReady(obj) { - msg := "Reconciliation finished" - log.Info(msg) - r.Event(obj, corev1.EventTypeNormal, meta.SucceededReason, msg) - } - }() - - if !obj.ObjectMeta.DeletionTimestamp.IsZero() { - controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer) - result = ctrl.Result{} - return - } - - // Add finalizer first if not exist to avoid the race condition between init - // and delete. - // Note: Finalizers in general can only be added when the deletionTimestamp - // is not set. - if !controllerutil.ContainsFinalizer(obj, apiv1.NotificationFinalizer) { - controllerutil.AddFinalizer(obj, apiv1.NotificationFinalizer) - result = ctrl.Result{Requeue: true} - return - } - - // Return early if the object is suspended. - if obj.Spec.Suspend { - log.Info("Reconciliation is suspended for this object") - return ctrl.Result{}, nil - } - - return r.reconcile(ctx, obj) -} - -func (r *AlertReconciler) reconcile(ctx context.Context, alert *apiv1beta2.Alert) (ctrl.Result, error) { - // Mark the resource as under reconciliation. - conditions.MarkReconciling(alert, meta.ProgressingReason, "Reconciliation in progress") - - // Check if the provider exist and is ready. - if err := r.isProviderReady(ctx, alert); err != nil { - conditions.MarkFalse(alert, meta.ReadyCondition, meta.FailedReason, err.Error()) - return ctrl.Result{Requeue: true}, client.IgnoreNotFound(err) - } - - conditions.MarkTrue(alert, meta.ReadyCondition, meta.SucceededReason, apiv1.InitializedReason) - - return ctrl.Result{}, nil -} - -func (r *AlertReconciler) isProviderReady(ctx context.Context, alert *apiv1beta2.Alert) error { - provider := &apiv1beta2.Provider{} - providerName := types.NamespacedName{Namespace: alert.Namespace, Name: alert.Spec.ProviderRef.Name} - if err := r.Get(ctx, providerName, provider); err != nil { - // log not found errors since they get filtered out - ctrl.LoggerFrom(ctx).Error(err, "failed to get provider", "provider", providerName.String()) - return fmt.Errorf("failed to get provider '%s': %w", providerName.String(), err) - } - - if !conditions.IsReady(provider) { - return fmt.Errorf("provider %s is not ready", providerName.String()) - } - - return nil -} - -func (r *AlertReconciler) requestsForProviderChange(ctx context.Context, o client.Object) []reconcile.Request { - log := ctrl.LoggerFrom(ctx) - provider, ok := o.(*apiv1beta2.Provider) - if !ok { - log.Error(fmt.Errorf("expected a Provider object; got %T", o), - "failed to get reconcile requests for Provider change") - return nil - } - - var list apiv1beta2.AlertList - if err := r.List(ctx, &list, client.MatchingFields{ - ProviderIndexKey: client.ObjectKeyFromObject(provider).String(), - }); err != nil { - return nil - } - - var reqs []reconcile.Request - for i := range list.Items { - reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])}) - } - - return reqs -} - -// patch updates the object status, conditions and finalizers. -func (r *AlertReconciler) patch(ctx context.Context, obj *apiv1beta2.Alert, patcher *patch.SerialPatcher) (retErr error) { - // Configure the runtime patcher. - patchOpts := []patch.Option{} - ownedConditions := []string{ - meta.ReadyCondition, - meta.ReconcilingCondition, - meta.StalledCondition, - } - patchOpts = append(patchOpts, - patch.WithOwnedConditions{Conditions: ownedConditions}, - patch.WithForceOverwriteConditions{}, - patch.WithFieldOwner(r.ControllerName), - ) - - // Set the value of the reconciliation request in status. - if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok { - obj.Status.LastHandledReconcileAt = v - } - - // Remove the Reconciling condition and update the observed generation - // if the reconciliation was successful. - if conditions.IsTrue(obj, meta.ReadyCondition) { - conditions.Delete(obj, meta.ReconcilingCondition) - obj.Status.ObservedGeneration = obj.Generation - } - - // Set the Reconciling reason to ProgressingWithRetry if the - // reconciliation has failed. - if conditions.IsFalse(obj, meta.ReadyCondition) && - conditions.Has(obj, meta.ReconcilingCondition) { - rc := conditions.Get(obj, meta.ReconcilingCondition) - rc.Reason = meta.ProgressingWithRetryReason - conditions.Set(obj, rc) - } - - // Patch the object status, conditions and finalizers. - if err := patcher.Patch(ctx, obj, patchOpts...); err != nil { - if !obj.GetDeletionTimestamp().IsZero() { - err = kerrors.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) - } - retErr = kerrors.NewAggregate([]error{retErr, err}) - if retErr != nil { - return retErr - } - } - - return nil -} diff --git a/internal/controller/alert_controller_test.go b/internal/controller/alert_controller_test.go deleted file mode 100644 index ea97151d4..000000000 --- a/internal/controller/alert_controller_test.go +++ /dev/null @@ -1,548 +0,0 @@ -/* -Copyright 2022 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 controller - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/fluxcd/pkg/ssa" - . "github.com/onsi/gomega" - "github.com/sethvargo/go-limiter/memorystore" - prommetrics "github.com/slok/go-http-metrics/metrics/prometheus" - "github.com/slok/go-http-metrics/middleware" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - logf "sigs.k8s.io/controller-runtime/pkg/log" - - eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" - "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/runtime/conditions" - - apiv1 "github.com/fluxcd/notification-controller/api/v1" - apiv1beta2 "github.com/fluxcd/notification-controller/api/v1beta2" - "github.com/fluxcd/notification-controller/internal/server" -) - -func TestAlertReconciler_deleteBeforeFinalizer(t *testing.T) { - g := NewWithT(t) - - namespaceName := "alert-" + randStringRunes(5) - namespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: namespaceName}, - } - g.Expect(k8sClient.Create(ctx, namespace)).ToNot(HaveOccurred()) - t.Cleanup(func() { - g.Expect(k8sClient.Delete(ctx, namespace)).NotTo(HaveOccurred()) - }) - - alert := &apiv1beta2.Alert{} - alert.Name = "test-alert" - alert.Namespace = namespaceName - alert.Spec.EventSources = []apiv1.CrossNamespaceObjectReference{ - {Kind: "Bucket", Name: "Foo"}, - } - // Add a test finalizer to prevent the object from getting deleted. - alert.SetFinalizers([]string{"test-finalizer"}) - g.Expect(k8sClient.Create(ctx, alert)).NotTo(HaveOccurred()) - // Add deletion timestamp by deleting the object. - g.Expect(k8sClient.Delete(ctx, alert)).NotTo(HaveOccurred()) - - r := &AlertReconciler{ - Client: k8sClient, - EventRecorder: record.NewFakeRecorder(32), - } - // NOTE: Only a real API server responds with an error in this scenario. - _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(alert)}) - g.Expect(err).NotTo(HaveOccurred()) -} - -func TestAlertReconciler_Reconcile(t *testing.T) { - g := NewWithT(t) - timeout := 5 * time.Second - resultA := &apiv1beta2.Alert{} - namespaceName := "alert-" + randStringRunes(5) - providerName := "provider-" + randStringRunes(5) - - g.Expect(createNamespace(namespaceName)).NotTo(HaveOccurred(), "failed to create test namespace") - - provider := &apiv1beta2.Provider{ - ObjectMeta: metav1.ObjectMeta{ - Name: providerName, - Namespace: namespaceName, - }, - Spec: apiv1beta2.ProviderSpec{ - Type: "generic", - Address: "https://webhook.internal", - }, - } - - alert := &apiv1beta2.Alert{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("alert-%s", randStringRunes(5)), - Namespace: namespaceName, - }, - Spec: apiv1beta2.AlertSpec{ - ProviderRef: meta.LocalObjectReference{ - Name: providerName, - }, - EventSeverity: "info", - EventSources: []apiv1.CrossNamespaceObjectReference{ - { - Kind: "Bucket", - Name: "*", - }, - }, - }, - } - g.Expect(k8sClient.Create(context.Background(), alert)).To(Succeed()) - - t.Run("fails with provider not found error", func(t *testing.T) { - g := NewWithT(t) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA) - return conditions.Has(resultA, meta.ReadyCondition) - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(conditions.IsReady(resultA)).To(BeFalse()) - g.Expect(conditions.GetReason(resultA, meta.ReadyCondition)).To(BeIdenticalTo(meta.FailedReason)) - g.Expect(conditions.GetMessage(resultA, meta.ReadyCondition)).To(ContainSubstring(providerName)) - - g.Expect(conditions.Has(resultA, meta.ReconcilingCondition)).To(BeTrue()) - g.Expect(conditions.GetReason(resultA, meta.ReconcilingCondition)).To(BeIdenticalTo(meta.ProgressingWithRetryReason)) - g.Expect(conditions.GetObservedGeneration(resultA, meta.ReconcilingCondition)).To(BeIdenticalTo(resultA.Generation)) - g.Expect(controllerutil.ContainsFinalizer(resultA, apiv1.NotificationFinalizer)).To(BeTrue()) - }) - - t.Run("recovers when provider exists", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA) - return conditions.IsReady(resultA) - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(conditions.GetObservedGeneration(resultA, meta.ReadyCondition)).To(BeIdenticalTo(resultA.Generation)) - g.Expect(resultA.Status.ObservedGeneration).To(BeIdenticalTo(resultA.Generation)) - g.Expect(conditions.Has(resultA, meta.ReconcilingCondition)).To(BeFalse()) - }) - - t.Run("handles reconcileAt", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA)).To(Succeed()) - - reconcileRequestAt := metav1.Now().String() - resultA.SetAnnotations(map[string]string{ - meta.ReconcileRequestAnnotation: reconcileRequestAt, - }) - g.Expect(k8sClient.Update(context.Background(), resultA)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA) - return resultA.Status.LastHandledReconcileAt == reconcileRequestAt - }, timeout, time.Second).Should(BeTrue()) - }) - - t.Run("finalizes suspended object", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA)).To(Succeed()) - - resultA.Spec.Suspend = true - g.Expect(k8sClient.Update(context.Background(), resultA)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA) - return resultA.Spec.Suspend == true - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(k8sClient.Delete(context.Background(), resultA)).To(Succeed()) - - g.Eventually(func() bool { - err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA) - return apierrors.IsNotFound(err) - }, timeout, time.Second).Should(BeTrue()) - }) -} - -func TestAlertReconciler_EventHandler(t *testing.T) { - g := NewWithT(t) - var ( - namespace = "events-" + randStringRunes(5) - req *http.Request - provider *apiv1beta2.Provider - ) - g.Expect(createNamespace(namespace)).NotTo(HaveOccurred(), "failed to create test namespace") - - eventMdlw := middleware.New(middleware.Config{ - Recorder: prommetrics.NewRecorder(prommetrics.Config{ - Prefix: "gotk_event", - }), - }) - - store, err := memorystore.New(&memorystore.Config{ - Interval: 5 * time.Minute, - }) - if err != nil { - t.Fatalf("failed to create memory storage") - } - - eventServer := server.NewEventServer("127.0.0.1:56789", logf.Log, k8sClient, true) - stopCh := make(chan struct{}) - go eventServer.ListenAndServe(stopCh, eventMdlw, store) - - rcvServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - req = r - w.WriteHeader(200) - })) - defer rcvServer.Close() - defer close(stopCh) - - providerKey := types.NamespacedName{ - Name: fmt.Sprintf("provider-%s", randStringRunes(5)), - Namespace: namespace, - } - provider = &apiv1beta2.Provider{ - ObjectMeta: metav1.ObjectMeta{ - Name: providerKey.Name, - Namespace: providerKey.Namespace, - }, - Spec: apiv1beta2.ProviderSpec{ - Type: "generic", - Address: rcvServer.URL, - }, - } - g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) - g.Eventually(func() bool { - var obj apiv1beta2.Provider - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), &obj)) - return conditions.IsReady(&obj) - }, 30*time.Second, time.Second).Should(BeTrue()) - - repo, err := readManifest("./testdata/repo.yaml", namespace) - g.Expect(err).ToNot(HaveOccurred()) - - secondRepo, err := readManifest("./testdata/gitrepo2.yaml", namespace) - g.Expect(err).ToNot(HaveOccurred()) - - _, err = manager.Apply(context.Background(), repo, ssa.ApplyOptions{ - Force: true, - }) - g.Expect(err).ToNot(HaveOccurred()) - - _, err = manager.Apply(context.Background(), secondRepo, ssa.ApplyOptions{ - Force: true, - }) - g.Expect(err).ToNot(HaveOccurred()) - - alertKey := types.NamespacedName{ - Name: fmt.Sprintf("alert-%s", randStringRunes(5)), - Namespace: namespace, - } - - alert := &apiv1beta2.Alert{ - ObjectMeta: metav1.ObjectMeta{ - Name: alertKey.Name, - Namespace: alertKey.Namespace, - }, - Spec: apiv1beta2.AlertSpec{ - ProviderRef: meta.LocalObjectReference{ - Name: providerKey.Name, - }, - EventSeverity: "info", - EventSources: []apiv1.CrossNamespaceObjectReference{ - { - Kind: "Bucket", - Name: "hyacinth", - Namespace: namespace, - }, - { - Kind: "Kustomization", - Name: "*", - }, - { - Kind: "GitRepository", - Name: "*", - MatchLabels: map[string]string{ - "app": "podinfo", - }, - }, - { - Kind: "Kustomization", - Name: "*", - Namespace: "test", - }, - }, - ExclusionList: []string{ - "doesnotoccur", // not intended to match - "excluded", - }, - }, - } - inclusionAlert := alert.DeepCopy() - inclusionAlert.Spec.InclusionList = []string{"^included"} - - g.Expect(k8sClient.Create(context.Background(), alert)).To(Succeed()) - - // wait for controller to mark the alert as ready - g.Eventually(func() bool { - var obj apiv1beta2.Alert - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), &obj)) - return conditions.IsReady(&obj) - }, 30*time.Second, time.Second).Should(BeTrue()) - - // An event fixture to set the initial fixed state of an Event which is - // modified in the test cases. - eventFixture := eventv1.Event{ - InvolvedObject: corev1.ObjectReference{ - Kind: "Bucket", - Name: "hyacinth", - Namespace: namespace, - }, - Severity: "info", - Timestamp: metav1.Now(), - Message: "well that happened", - Reason: "event-happened", - ReportingController: "source-controller", - } - event := *eventFixture.DeepCopy() - - testSent := func(g *WithT) { - g.THelper() - buf := &bytes.Buffer{} - g.Expect(json.NewEncoder(buf).Encode(&event)).To(Succeed()) - res, err := http.Post("http://localhost:56789/", "application/json", buf) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(res.StatusCode).To(Equal(202)) // event_server responds with 202 Accepted - } - - testForwarded := func(g *WithT) { - g.THelper() - g.Eventually(func() bool { - return req == nil - }, "2s", "0.1s").Should(BeFalse()) - } - - testFiltered := func(g *WithT) { - g.THelper() - // The event_server does forwarding in a goroutine, after - // responding to the POST of the event. This makes it - // difficult to know whether the provider has filtered the - // event, or just not run the goroutine yet. For now, I'll use - // a timeout (and Consistently so it can fail early) - g.Consistently(func() bool { - return req == nil - }, "1s", "0.1s").Should(BeTrue()) - } - - tests := []struct { - name string - modifyEventFunc func(e eventv1.Event) eventv1.Event - forwarded bool - }{ - { - name: "forwards when source is a match", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { return e }, - forwarded: true, - }, - { - name: "drops event when source Kind does not match", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.InvolvedObject.Kind = "GitRepository" - return e - }, - forwarded: false, - }, - { - name: "drops event when source name does not match", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.InvolvedObject.Name = "slop" - return e - }, - forwarded: false, - }, - { - name: "drops event when source namespace does not match", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.InvolvedObject.Namespace = "all-buckets" - return e - }, - forwarded: false, - }, - { - name: "drops event that is matched by exclusion", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.Message = "this is excluded" - return e - }, - forwarded: false, - }, - { - name: "forwards events when name wildcard is used", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.InvolvedObject.Kind = "Kustomization" - e.InvolvedObject.Name = "test" - e.InvolvedObject.Namespace = namespace - e.Message = "test" - return e - }, - forwarded: true, - }, - { - name: "forwards events when the label matches", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.InvolvedObject.Kind = "GitRepository" - e.InvolvedObject.Name = "podinfo" - e.InvolvedObject.APIVersion = "source.toolkit.fluxcd.io/v1" - e.InvolvedObject.Namespace = namespace - e.Message = "test" - return e - }, - forwarded: true, - }, - { - name: "drops events when the labels don't match", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.InvolvedObject.Kind = "GitRepository" - e.InvolvedObject.Name = "podinfo-two" - e.InvolvedObject.APIVersion = "source.toolkit.fluxcd.io/v1" - e.InvolvedObject.Namespace = namespace - e.Message = "test" - return e - }, - forwarded: false, - }, - { - name: "drops events for cross-namespace sources", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.InvolvedObject.Kind = "Kustomization" - e.InvolvedObject.Name = "test" - e.InvolvedObject.Namespace = "test" - e.Message = "test" - return e - }, - forwarded: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := NewWithT(t) - // Reset the common variables to their fixture value. - req = nil - event = *eventFixture.DeepCopy() - - event = tt.modifyEventFunc(event) - testSent(g) - if tt.forwarded { - testForwarded(g) - } else { - testFiltered(g) - } - }) - } - - // update alert for testing inclusion list - var obj apiv1beta2.Alert - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), &obj)).To(Succeed()) - inclusionAlert.ResourceVersion = obj.ResourceVersion - g.Expect(k8sClient.Update(context.Background(), inclusionAlert)).To(Succeed()) - - // wait for ready - g.Eventually(func() bool { - var obj apiv1beta2.Alert - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(inclusionAlert), &obj)) - return conditions.IsReady(&obj) - }, 30*time.Second, time.Second).Should(BeTrue()) - - // An event fixture to set the initial fixed state of an Event which is - // modified in the test cases. - eventFixture2 := eventv1.Event{ - InvolvedObject: corev1.ObjectReference{ - Kind: "Bucket", - Name: "hyacinth", - Namespace: namespace, - }, - Severity: "info", - Timestamp: metav1.Now(), - Message: "included", - Reason: "event-happened", - ReportingController: "source-controller", - } - event = *eventFixture2.DeepCopy() - - tests = []struct { - name string - modifyEventFunc func(e eventv1.Event) eventv1.Event - forwarded bool - }{ - { - name: "forwards when message matches inclusion list", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { return e }, - forwarded: true, - }, - { - name: "drops when message does not match inclusion list", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.Message = "not included" - return e - }, - forwarded: false, - }, - { - name: "drops when message matches inclusion list and exclusion list", - modifyEventFunc: func(e eventv1.Event) eventv1.Event { - e.Message = "included excluded" - return e - }, - forwarded: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := NewWithT(t) - // Reset the common variables to their fixture value. - req = nil - event = *eventFixture2.DeepCopy() - - event = tt.modifyEventFunc(event) - testSent(g) - if tt.forwarded { - testForwarded(g) - } else { - testFiltered(g) - } - }) - } -} diff --git a/internal/controller/provider_controller.go b/internal/controller/provider_controller.go deleted file mode 100644 index d1b753b44..000000000 --- a/internal/controller/provider_controller.go +++ /dev/null @@ -1,334 +0,0 @@ -/* -Copyright 2022 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 controller - -import ( - "context" - "crypto/x509" - "fmt" - "net/url" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - kerrors "k8s.io/apimachinery/pkg/util/errors" - kuberecorder "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/ratelimiter" - "sigs.k8s.io/yaml" - - "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/runtime/conditions" - helper "github.com/fluxcd/pkg/runtime/controller" - "github.com/fluxcd/pkg/runtime/patch" - "github.com/fluxcd/pkg/runtime/predicates" - - apiv1 "github.com/fluxcd/notification-controller/api/v1" - apiv1beta2 "github.com/fluxcd/notification-controller/api/v1beta2" - "github.com/fluxcd/notification-controller/internal/notifier" -) - -// ProviderReconciler reconciles a Provider object -type ProviderReconciler struct { - client.Client - helper.Metrics - kuberecorder.EventRecorder - - ControllerName string -} - -type ProviderReconcilerOptions struct { - RateLimiter ratelimiter.RateLimiter -} - -func (r *ProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { - return r.SetupWithManagerAndOptions(mgr, ProviderReconcilerOptions{}) -} - -func (r *ProviderReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts ProviderReconcilerOptions) error { - return ctrl.NewControllerManagedBy(mgr). - For(&apiv1beta2.Provider{}, builder.WithPredicates( - predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}), - )). - WithOptions(controller.Options{ - RateLimiter: opts.RateLimiter, - }). - Complete(r) -} - -// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=providers,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=providers/status,verbs=get;update;patch -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch - -func (r *ProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { - reconcileStart := time.Now() - log := ctrl.LoggerFrom(ctx) - - obj := &apiv1beta2.Provider{} - if err := r.Get(ctx, req.NamespacedName, obj); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - - // Initialize the runtime patcher with the current version of the object. - patcher := patch.NewSerialPatcher(obj, r.Client) - - defer func() { - // Patch finalizers, status and conditions. - if err := r.patch(ctx, obj, patcher); err != nil { - retErr = kerrors.NewAggregate([]error{retErr, err}) - } - - // Record Prometheus metrics. - r.Metrics.RecordReadiness(ctx, obj) - r.Metrics.RecordDuration(ctx, obj, reconcileStart) - r.Metrics.RecordSuspend(ctx, obj, obj.Spec.Suspend) - - // Emit warning event if the reconciliation failed. - if retErr != nil { - r.Event(obj, corev1.EventTypeWarning, meta.FailedReason, retErr.Error()) - } - - // Log the staleness error and pause reconciliation until spec changes. - if conditions.IsStalled(obj) { - result = ctrl.Result{Requeue: false} - log.Error(retErr, "Reconciliation has stalled") - retErr = nil - return - } - - // Log and emit success event. - if retErr == nil && conditions.IsReady(obj) { - msg := fmt.Sprintf("Reconciliation finished, next run in %s", - obj.GetInterval().String()) - log.Info(msg) - r.Event(obj, corev1.EventTypeNormal, meta.SucceededReason, msg) - } - }() - - if !obj.ObjectMeta.DeletionTimestamp.IsZero() { - controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer) - result = ctrl.Result{} - return - } - - // Add finalizer first if not exist to avoid the race condition - // between init and delete. - // Note: Finalizers in general can only be added when the deletionTimestamp - // is not set. - if !controllerutil.ContainsFinalizer(obj, apiv1.NotificationFinalizer) { - controllerutil.AddFinalizer(obj, apiv1.NotificationFinalizer) - result = ctrl.Result{Requeue: true} - return - } - - // Return early if the object is suspended. - if obj.Spec.Suspend { - log.Info("Reconciliation is suspended for this object") - return ctrl.Result{}, nil - } - - return r.reconcile(ctx, obj) -} - -func (r *ProviderReconciler) reconcile(ctx context.Context, obj *apiv1beta2.Provider) (ctrl.Result, error) { - // Mark the resource as under reconciliation. - conditions.MarkReconciling(obj, meta.ProgressingReason, "Reconciliation in progress") - conditions.Delete(obj, meta.StalledCondition) - - // Mark the reconciliation as stalled if the inline URL and/or proxy are invalid. - if err := r.validateURLs(obj); err != nil { - conditions.MarkFalse(obj, meta.ReadyCondition, meta.InvalidURLReason, err.Error()) - conditions.MarkTrue(obj, meta.StalledCondition, meta.InvalidURLReason, err.Error()) - return ctrl.Result{Requeue: true}, err - } - - // Validate the provider credentials. - if err := r.validateCredentials(ctx, obj); err != nil { - conditions.MarkFalse(obj, meta.ReadyCondition, apiv1.ValidationFailedReason, err.Error()) - return ctrl.Result{Requeue: true}, err - } - - conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, apiv1.InitializedReason) - - return ctrl.Result{RequeueAfter: obj.GetInterval()}, nil -} - -func (r *ProviderReconciler) validateURLs(provider *apiv1beta2.Provider) error { - address := provider.Spec.Address - proxy := provider.Spec.Proxy - - if provider.Spec.SecretRef == nil { - if _, err := url.ParseRequestURI(address); err != nil { - return fmt.Errorf("invalid address %s: %w", address, err) - } - if _, err := url.ParseRequestURI(proxy); proxy != "" && err != nil { - return fmt.Errorf("invalid proxy %s: %w", proxy, err) - } - } - return nil -} - -func (r *ProviderReconciler) validateCredentials(ctx context.Context, provider *apiv1beta2.Provider) error { - log := ctrl.LoggerFrom(ctx) - - address := provider.Spec.Address - proxy := provider.Spec.Proxy - username := provider.Spec.Username - password := "" - token := "" - headers := make(map[string]string) - if provider.Spec.SecretRef != nil { - var secret corev1.Secret - secretName := types.NamespacedName{Namespace: provider.Namespace, Name: provider.Spec.SecretRef.Name} - - if err := r.Get(ctx, secretName, &secret); err != nil { - return fmt.Errorf("failed to read secret, error: %w", err) - } - - if a, ok := secret.Data["address"]; ok { - address = string(a) - } - - if p, ok := secret.Data["password"]; ok { - password = string(p) - } - - if p, ok := secret.Data["proxy"]; ok { - proxy = string(p) - } - - if t, ok := secret.Data["token"]; ok { - token = string(t) - } - - if u, ok := secret.Data["username"]; ok { - username = string(u) - } - - if h, ok := secret.Data["headers"]; ok { - err := yaml.Unmarshal(h, headers) - if err != nil { - return fmt.Errorf("failed to read headers from secret, error: %w", err) - } - } - } - - if address == "" { - return fmt.Errorf("no address found in 'spec.address' nor in `spec.secretRef`") - } - - var certPool *x509.CertPool - if provider.Spec.CertSecretRef != nil { - var secret corev1.Secret - secretName := types.NamespacedName{Namespace: provider.Namespace, Name: provider.Spec.CertSecretRef.Name} - - if err := r.Get(ctx, secretName, &secret); err != nil { - return fmt.Errorf("failed to read secret, error: %w", err) - } - - switch secret.Type { - case corev1.SecretTypeOpaque, corev1.SecretTypeTLS, "": - default: - return fmt.Errorf("cannot use secret '%s' to get TLS certificate: invalid secret type: '%s'", secret.Name, secret.Type) - } - - caFile, ok := secret.Data["ca.crt"] - if !ok { - caFile, ok = secret.Data["caFile"] - if !ok { - return fmt.Errorf("no 'ca.crt' key found in secret '%s'", provider.Spec.CertSecretRef.Name) - } - log.Info("warning: specifying CA cert via 'caFile' is deprecated, please use 'ca.crt' instead") - } - - certPool = x509.NewCertPool() - ok = certPool.AppendCertsFromPEM(caFile) - if !ok { - return fmt.Errorf("could not append to cert pool: invalid CA found in %s", provider.Spec.CertSecretRef.Name) - } - } - - factory := notifier.NewFactory(address, proxy, username, provider.Spec.Channel, token, headers, certPool, password, string(provider.UID)) - if _, err := factory.Notifier(provider.Spec.Type); err != nil { - return fmt.Errorf("failed to initialize provider, error: %w", err) - } - - return nil -} - -// patch updates the object status, conditions and finalizers. -func (r *ProviderReconciler) patch(ctx context.Context, obj *apiv1beta2.Provider, patcher *patch.SerialPatcher) (retErr error) { - // Configure the runtime patcher. - patchOpts := []patch.Option{} - ownedConditions := []string{ - meta.ReadyCondition, - meta.ReconcilingCondition, - meta.StalledCondition, - } - patchOpts = append(patchOpts, - patch.WithOwnedConditions{Conditions: ownedConditions}, - patch.WithForceOverwriteConditions{}, - patch.WithFieldOwner(r.ControllerName), - ) - - // Set the value of the reconciliation request in status. - if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok { - obj.Status.LastHandledReconcileAt = v - } - - // Remove the Reconciling/Stalled condition and update the observed generation - // if the reconciliation was successful. - if conditions.IsTrue(obj, meta.ReadyCondition) { - conditions.Delete(obj, meta.ReconcilingCondition) - conditions.Delete(obj, meta.StalledCondition) - obj.Status.ObservedGeneration = obj.Generation - } - - // Set the Reconciling reason to ProgressingWithRetry if the - // reconciliation has failed. - if conditions.IsFalse(obj, meta.ReadyCondition) && - conditions.Has(obj, meta.ReconcilingCondition) { - rc := conditions.Get(obj, meta.ReconcilingCondition) - rc.Reason = meta.ProgressingWithRetryReason - conditions.Set(obj, rc) - } - - // Remove the Reconciling condition if the reconciliation has stalled. - if conditions.Has(obj, meta.StalledCondition) { - conditions.Delete(obj, meta.ReconcilingCondition) - } - - // Patch the object status, conditions and finalizers. - if err := patcher.Patch(ctx, obj, patchOpts...); err != nil { - if !obj.GetDeletionTimestamp().IsZero() { - err = kerrors.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) - } - retErr = kerrors.NewAggregate([]error{retErr, err}) - if retErr != nil { - return retErr - } - } - - return nil -} diff --git a/internal/controller/provider_controller_test.go b/internal/controller/provider_controller_test.go deleted file mode 100644 index 652156acb..000000000 --- a/internal/controller/provider_controller_test.go +++ /dev/null @@ -1,336 +0,0 @@ -/* -Copyright 2022 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 controller - -import ( - "context" - "fmt" - "os" - "testing" - "time" - - . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - "github.com/fluxcd/pkg/apis/meta" - "github.com/fluxcd/pkg/runtime/conditions" - "github.com/fluxcd/pkg/runtime/patch" - - apiv1 "github.com/fluxcd/notification-controller/api/v1" - apiv1beta2 "github.com/fluxcd/notification-controller/api/v1beta2" -) - -func TestProviderReconciler_deleteBeforeFinalizer(t *testing.T) { - g := NewWithT(t) - - namespaceName := "provider-" + randStringRunes(5) - namespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: namespaceName}, - } - g.Expect(k8sClient.Create(ctx, namespace)).ToNot(HaveOccurred()) - t.Cleanup(func() { - g.Expect(k8sClient.Delete(ctx, namespace)).NotTo(HaveOccurred()) - }) - - provider := &apiv1beta2.Provider{} - provider.Name = "test-provider" - provider.Namespace = namespaceName - provider.Spec.Type = "slack" - // Add a test finalizer to prevent the object from getting deleted. - provider.SetFinalizers([]string{"test-finalizer"}) - g.Expect(k8sClient.Create(ctx, provider)).NotTo(HaveOccurred()) - // Add deletion timestamp by deleting the object. - g.Expect(k8sClient.Delete(ctx, provider)).NotTo(HaveOccurred()) - - r := &ProviderReconciler{ - Client: k8sClient, - EventRecorder: record.NewFakeRecorder(32), - } - // NOTE: Only a real API server responds with an error in this scenario. - _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(provider)}) - g.Expect(err).NotTo(HaveOccurred()) -} - -func TestProviderReconciler_Reconcile(t *testing.T) { - g := NewWithT(t) - timeout := 5 * time.Second - resultP := &apiv1beta2.Provider{} - namespaceName := "provider-" + randStringRunes(5) - secretName := "secret-" + randStringRunes(5) - - g.Expect(createNamespace(namespaceName)).NotTo(HaveOccurred(), "failed to create test namespace") - - providerKey := types.NamespacedName{ - Name: fmt.Sprintf("provider-%s", randStringRunes(5)), - Namespace: namespaceName, - } - provider := &apiv1beta2.Provider{ - ObjectMeta: metav1.ObjectMeta{ - Name: providerKey.Name, - Namespace: providerKey.Namespace, - }, - Spec: apiv1beta2.ProviderSpec{ - Type: "generic", - Address: "https://webhook.internal", - }, - } - g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) - - t.Run("reports ready status", func(t *testing.T) { - g := NewWithT(t) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return resultP.Status.ObservedGeneration == resultP.Generation - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(conditions.IsReady(resultP)).To(BeTrue()) - g.Expect(conditions.GetReason(resultP, meta.ReadyCondition)).To(BeIdenticalTo(meta.SucceededReason)) - - g.Expect(conditions.Has(resultP, meta.ReconcilingCondition)).To(BeFalse()) - g.Expect(controllerutil.ContainsFinalizer(resultP, apiv1.NotificationFinalizer)).To(BeTrue()) - }) - - t.Run("fails with secret not found error", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP)).To(Succeed()) - - resultP.Spec.SecretRef = &meta.LocalObjectReference{ - Name: secretName, - } - g.Expect(k8sClient.Update(context.Background(), resultP)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return !conditions.IsReady(resultP) - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(conditions.GetReason(resultP, meta.ReadyCondition)).To(BeIdenticalTo(apiv1.ValidationFailedReason)) - g.Expect(conditions.GetMessage(resultP, meta.ReadyCondition)).To(ContainSubstring(secretName)) - - g.Expect(conditions.Has(resultP, meta.ReconcilingCondition)).To(BeTrue()) - g.Expect(conditions.GetReason(resultP, meta.ReconcilingCondition)).To(BeIdenticalTo(meta.ProgressingWithRetryReason)) - g.Expect(conditions.GetObservedGeneration(resultP, meta.ReconcilingCondition)).To(BeIdenticalTo(resultP.Generation)) - g.Expect(resultP.Status.ObservedGeneration).To(BeIdenticalTo(resultP.Generation - 1)) - }) - - t.Run("recovers when secret exists", func(t *testing.T) { - g := NewWithT(t) - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespaceName, - }, - StringData: map[string]string{ - "token": "test", - }, - } - g.Expect(k8sClient.Create(context.Background(), secret)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return conditions.IsReady(resultP) - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(conditions.GetObservedGeneration(resultP, meta.ReadyCondition)).To(BeIdenticalTo(resultP.Generation)) - g.Expect(resultP.Status.ObservedGeneration).To(BeIdenticalTo(resultP.Generation)) - g.Expect(conditions.Has(resultP, meta.ReconcilingCondition)).To(BeFalse()) - }) - - t.Run("handles reconcileAt", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP)).To(Succeed()) - - reconcileRequestAt := metav1.Now().String() - resultP.SetAnnotations(map[string]string{ - meta.ReconcileRequestAnnotation: reconcileRequestAt, - }) - g.Expect(k8sClient.Update(context.Background(), resultP)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return resultP.Status.LastHandledReconcileAt == reconcileRequestAt - }, timeout, time.Second).Should(BeTrue()) - }) - - t.Run("becomes stalled on invalid proxy", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP)).To(Succeed()) - - resultP.Spec.SecretRef = nil - resultP.Spec.Proxy = "https://proxy.internal|" - g.Expect(k8sClient.Update(context.Background(), resultP)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return !conditions.IsReady(resultP) - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(conditions.Has(resultP, meta.ReconcilingCondition)).To(BeFalse()) - g.Expect(conditions.Has(resultP, meta.StalledCondition)).To(BeTrue()) - g.Expect(conditions.GetObservedGeneration(resultP, meta.StalledCondition)).To(BeIdenticalTo(resultP.Generation)) - g.Expect(conditions.GetReason(resultP, meta.StalledCondition)).To(BeIdenticalTo(meta.InvalidURLReason)) - g.Expect(conditions.GetReason(resultP, meta.ReadyCondition)).To(BeIdenticalTo(meta.InvalidURLReason)) - }) - - t.Run("recovers from staleness", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP)).To(Succeed()) - - resultP.Spec.Proxy = "https://proxy.internal" - g.Expect(k8sClient.Update(context.Background(), resultP)).To(Succeed()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return conditions.IsReady(resultP) - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(conditions.Has(resultP, meta.ReconcilingCondition)).To(BeFalse()) - g.Expect(conditions.Has(resultP, meta.StalledCondition)).To(BeFalse()) - }) - - t.Run("finalizes suspended object", func(t *testing.T) { - g := NewWithT(t) - - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP)).To(Succeed()) - - patchHelper, err := patch.NewHelper(resultP, k8sClient) - g.Expect(err).ToNot(HaveOccurred()) - resultP.Spec.Suspend = true - g.Expect(patchHelper.Patch(context.Background(), resultP)).ToNot(HaveOccurred()) - - g.Eventually(func() bool { - _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return resultP.Spec.Suspend == true - }, timeout, time.Second).Should(BeTrue()) - - g.Expect(k8sClient.Delete(context.Background(), resultP)).To(Succeed()) - - g.Eventually(func() bool { - err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) - return apierrors.IsNotFound(err) - }, timeout, time.Second).Should(BeTrue()) - }) -} - -func TestProviderReconciler_Reconcile_cacert(t *testing.T) { - g := NewWithT(t) - namespaceName := "provider-" + randStringRunes(5) - secretName := "ca-secret-" + randStringRunes(5) - - caCrt, err := os.ReadFile("./testdata/certs/ca.pem") - g.Expect(err).To(Not(HaveOccurred())) - - g.Expect(createNamespace(namespaceName)).NotTo(HaveOccurred(), "failed to create test namespace") - - providerKey := types.NamespacedName{ - Name: fmt.Sprintf("provider-%s", randStringRunes(5)), - Namespace: namespaceName, - } - - provider := &apiv1beta2.Provider{ - ObjectMeta: metav1.ObjectMeta{ - Name: providerKey.Name, - Namespace: providerKey.Namespace, - }, - Spec: apiv1beta2.ProviderSpec{ - Type: "generic", - Address: "https://webhook.internal", - CertSecretRef: &meta.LocalObjectReference{Name: secretName}, - }, - } - g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) - - certSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: providerKey.Namespace, - }, - Data: map[string][]byte{ - "caFile": []byte("invalid byte"), - "ca.crt": caCrt, - }, - } - g.Expect(k8sClient.Create(context.Background(), certSecret)).To(Succeed()) - - r := &ProviderReconciler{ - Client: k8sClient, - EventRecorder: record.NewFakeRecorder(32), - } - - t.Run("uses `ca.crt` instead of deprecated `caFile`", func(t *testing.T) { - g := NewWithT(t) - _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(provider)}) - g.Expect(err).NotTo(HaveOccurred()) - }) - - t.Run("works if only deprecated `caFile` is specified", func(t *testing.T) { - g := NewWithT(t) - - clusterCertSecret := &corev1.Secret{} - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(certSecret), clusterCertSecret)).To(Succeed()) - - patchHelper, err := patch.NewHelper(clusterCertSecret, k8sClient) - g.Expect(err).ToNot(HaveOccurred()) - clusterCertSecret.Data = map[string][]byte{ - "caFile": caCrt, - } - g.Expect(patchHelper.Patch(context.Background(), clusterCertSecret)).ToNot(HaveOccurred()) - - _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(provider)}) - g.Expect(err).NotTo(HaveOccurred()) - }) - - t.Run("returns error with certSecretRef of the wrong type", func(t *testing.T) { - g := NewWithT(t) - - dockerSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "docker-secret", - Namespace: providerKey.Namespace, - }, - Type: corev1.DockerConfigJsonKey, - } - g.Expect(k8sClient.Create(context.Background(), dockerSecret)).To(Succeed()) - - clusterProvider := &apiv1beta2.Provider{} - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), clusterProvider)).To(Succeed()) - - patchHelper, err := patch.NewHelper(clusterProvider, k8sClient) - g.Expect(err).ToNot(HaveOccurred()) - clusterProvider.Spec.CertSecretRef = &meta.LocalObjectReference{ - Name: dockerSecret.Name, - } - g.Expect(patchHelper.Patch(context.Background(), clusterProvider)).ToNot(HaveOccurred()) - - _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(provider)}) - g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring("invalid secret type")) - }) -} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 5aa1ddbe2..e7d18f9d8 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -70,28 +70,6 @@ func TestMain(m *testing.M) { controllerName := "notification-controller" testMetricsH := controller.NewMetrics(testEnv, metrics.MustMakeRecorder(), apiv1.NotificationFinalizer) - if err := (&AlertReconciler{ - Client: testEnv, - Metrics: testMetricsH, - ControllerName: controllerName, - EventRecorder: testEnv.GetEventRecorderFor(controllerName), - }).SetupWithManagerAndOptions(testEnv, AlertReconcilerOptions{ - RateLimiter: controller.GetDefaultRateLimiter(), - }); err != nil { - panic(fmt.Sprintf("Failed to start AlerReconciler: %v", err)) - } - - if err := (&ProviderReconciler{ - Client: testEnv, - Metrics: testMetricsH, - ControllerName: controllerName, - EventRecorder: testEnv.GetEventRecorderFor(controllerName), - }).SetupWithManagerAndOptions(testEnv, ProviderReconcilerOptions{ - RateLimiter: controller.GetDefaultRateLimiter(), - }); err != nil { - panic(fmt.Sprintf("Failed to start ProviderReconciler: %v", err)) - } - if err := (&ReceiverReconciler{ Client: testEnv, Metrics: testMetricsH, diff --git a/internal/controller/testdata/certs/Makefile b/internal/controller/testdata/certs/Makefile deleted file mode 100644 index 2863849d4..000000000 --- a/internal/controller/testdata/certs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2023 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. - -all: ca-key.pem - -ca-key.pem: ca-csr.json - cfssl gencert -initca ca-csr.json | cfssljson -bare ca – -ca.pem: ca-key.pem -ca.csr: ca-key.pem diff --git a/internal/controller/testdata/certs/ca-config.json b/internal/controller/testdata/certs/ca-config.json deleted file mode 100644 index a63e0dd23..000000000 --- a/internal/controller/testdata/certs/ca-config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "signing": { - "default": { - "expiry": "8760h" - }, - "profiles": { - "kubernetes": { - "usages": ["signing", "key encipherment", "server auth", "client auth"], - "expiry": "8760h" - } - } - } -} diff --git a/internal/controller/testdata/certs/ca-csr.json b/internal/controller/testdata/certs/ca-csr.json deleted file mode 100644 index 941277bb1..000000000 --- a/internal/controller/testdata/certs/ca-csr.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "CN": "example.com CA", - "hosts": [ - "127.0.0.1", - "localhost", - "example.com", - "www.example.com" - ] -} diff --git a/internal/controller/testdata/certs/ca-key.pem b/internal/controller/testdata/certs/ca-key.pem deleted file mode 100644 index a3b86348a..000000000 --- a/internal/controller/testdata/certs/ca-key.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN EC PRIVATE KEY----- -MHcCAQEEIEY+R5pZqRQUvD2wrbL4HFl5IKFY84FEGHG5GHW1EojroAoGCCqGSM49 -AwEHoUQDQgAEDqdWaSYf2JT7L90ywnuhz2BS4zhy+v5juPLpBI0Indo8mHpOw9m+ -LCnMzN2WkcUYt+XLQwhaNNt0RBlfnXRzhw== ------END EC PRIVATE KEY----- diff --git a/internal/controller/testdata/certs/ca.csr b/internal/controller/testdata/certs/ca.csr deleted file mode 100644 index ee4314138..000000000 --- a/internal/controller/testdata/certs/ca.csr +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIBHzCBxgIBADAZMRcwFQYDVQQDEw5leGFtcGxlLmNvbSBDQTBZMBMGByqGSM49 -AgEGCCqGSM49AwEHA0IABA6nVmkmH9iU+y/dMsJ7oc9gUuM4cvr+Y7jy6QSNCJ3a -PJh6TsPZviwpzMzdlpHFGLfly0MIWjTbdEQZX510c4egSzBJBgkqhkiG9w0BCQ4x -PDA6MDgGA1UdEQQxMC+CCWxvY2FsaG9zdIILZXhhbXBsZS5jb22CD3d3dy5leGFt -cGxlLmNvbYcEfwAAATAKBggqhkjOPQQDAgNIADBFAiEAl0UiOdxlEcuKrNAGh/Pv -CFxMyX5shaeAsdGvq/gyXckCIFlTwheOJZZVRRQl9b0l5LUVeJyIH6mnvitFGyQ7 -NRk5 ------END CERTIFICATE REQUEST----- diff --git a/internal/controller/testdata/certs/ca.pem b/internal/controller/testdata/certs/ca.pem deleted file mode 100644 index 1c5fbc3b8..000000000 --- a/internal/controller/testdata/certs/ca.pem +++ /dev/null @@ -1,11 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIBhzCCAS2gAwIBAgIUXe+UdziaK3dAdwRgwpWa0XPkKLIwCgYIKoZIzj0EAwIw -GTEXMBUGA1UEAxMOZXhhbXBsZS5jb20gQ0EwHhcNMjMwODE2MTcwMDAwWhcNMjgw -ODE0MTcwMDAwWjAZMRcwFQYDVQQDEw5leGFtcGxlLmNvbSBDQTBZMBMGByqGSM49 -AgEGCCqGSM49AwEHA0IABA6nVmkmH9iU+y/dMsJ7oc9gUuM4cvr+Y7jy6QSNCJ3a -PJh6TsPZviwpzMzdlpHFGLfly0MIWjTbdEQZX510c4ejUzBRMA4GA1UdDwEB/wQE -AwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRSCe96Yb+o/q3kuqBp5IMs -uD9N/DAPBgNVHREECDAGhwR/AAABMAoGCCqGSM49BAMCA0gAMEUCIBw52JoEQ/0Z -Bnz4gXlXLXtVX0C4LvcNohdlwSRGHPlYAiEAnVqcT2kxBs2+E3SqJPU3DUM7ZFOO -n3zfiIVinQNlXPY= ------END CERTIFICATE----- diff --git a/main.go b/main.go index 75f949534..d10660e52 100644 --- a/main.go +++ b/main.go @@ -173,28 +173,6 @@ func main() { metricsH := helper.NewMetrics(mgr, metrics.MustMakeRecorder(), apiv1.NotificationFinalizer) - if err = (&controller.ProviderReconciler{ - Client: mgr.GetClient(), - ControllerName: controllerName, - Metrics: metricsH, - EventRecorder: mgr.GetEventRecorderFor(controllerName), - }).SetupWithManagerAndOptions(mgr, controller.ProviderReconcilerOptions{ - RateLimiter: helper.GetRateLimiter(rateLimiterOptions), - }); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Provider") - os.Exit(1) - } - if err = (&controller.AlertReconciler{ - Client: mgr.GetClient(), - ControllerName: controllerName, - Metrics: metricsH, - EventRecorder: mgr.GetEventRecorderFor(controllerName), - }).SetupWithManagerAndOptions(mgr, controller.AlertReconcilerOptions{ - RateLimiter: helper.GetRateLimiter(rateLimiterOptions), - }); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Alert") - os.Exit(1) - } if err = (&controller.ReceiverReconciler{ Client: mgr.GetClient(), ControllerName: controllerName, From 80f7237673faadade614d25f184e27c8e3aefcea Mon Sep 17 00:00:00 2001 From: Sunny Date: Wed, 25 Oct 2023 11:06:31 +0000 Subject: [PATCH 3/9] Refactor event handler - Break down the EventServer.handleEvent() implementation into multiple smaller functions which are extensively tested on their own. - New implementation of filter Alerts for Event - New implementation of Event matches Alert - Remove any readiness check on Alert or Provider. - Add kubebuilder marker for generating RBAC permissions to create and patch events, and query Alert and Provider objects. - Convert the event handler test from controllers/ dir to work with just EventServer without any reconciler, keeping all the test cases and slightly modified test set up code. Signed-off-by: Sunny --- internal/server/event_handlers.go | 522 ++++++----- internal/server/event_handlers_test.go | 949 +++++++++++++++++++- internal/server/event_server.go | 4 + internal/server/event_server_test.go | 402 ++++++++- internal/server/testdata/gitrepo2.yaml | 13 + internal/server/testdata/kustomization.yaml | 10 + internal/server/testdata/repo.yaml | 13 + 7 files changed, 1665 insertions(+), 248 deletions(-) create mode 100644 internal/server/testdata/gitrepo2.yaml create mode 100644 internal/server/testdata/kustomization.yaml create mode 100644 internal/server/testdata/repo.yaml diff --git a/internal/server/event_handlers.go b/internal/server/event_handlers.go index 1cd1adee1..a919e4796 100644 --- a/internal/server/event_handlers.go +++ b/internal/server/event_handlers.go @@ -22,10 +22,10 @@ import ( "errors" "fmt" "net/http" + "net/url" "regexp" "time" - "github.com/fluxcd/pkg/runtime/conditions" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -38,7 +38,7 @@ import ( "github.com/fluxcd/pkg/masktoken" apiv1 "github.com/fluxcd/notification-controller/api/v1" - apiv1beta2 "github.com/fluxcd/notification-controller/api/v1beta2" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" "github.com/fluxcd/notification-controller/internal/notifier" ) @@ -50,261 +50,353 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request) ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - var allAlerts apiv1beta2.AlertList - err := s.kubeClient.List(ctx, &allAlerts) + alerts, err := s.getAllAlertsForEvent(ctx, event) if err != nil { - eventLogger.Error(err, "listing alerts failed") - w.WriteHeader(http.StatusBadRequest) + eventLogger.Error(err, "failed to get alerts for the event") + } + + if len(alerts) == 0 { + eventLogger.Info("discarding event, no alerts found for the involved object") + w.WriteHeader(http.StatusAccepted) return } - // find matching alerts - alerts := make([]apiv1beta2.Alert, 0) - each_alert: - for i, alert := range allAlerts.Items { - alertLogger := eventLogger.WithValues("alert", client.ObjectKeyFromObject(&allAlerts.Items[i])) - ctx := log.IntoContext(ctx, alertLogger) + eventLogger.Info("dispatching event", "message", event.Message) - // skip suspended and not ready alerts - isReady := conditions.IsReady(&allAlerts.Items[i]) - if alert.Spec.Suspend || !isReady { - continue each_alert + // Dispatch notifications. + for i := range alerts { + alert := &alerts[i] + alertLogger := eventLogger.WithValues(alert.Kind, client.ObjectKeyFromObject(alert)) + ctx := log.IntoContext(ctx, alertLogger) + if err := s.dispatchNotification(ctx, event, *alert); err != nil { + alertLogger.Error(err, "failed to dispatch notification") } + } - // skip alert if the message does not match any regex from the inclusion list - if len(alert.Spec.InclusionList) > 0 { - var include bool - for _, inclusionRegex := range alert.Spec.InclusionList { - if r, err := regexp.Compile(inclusionRegex); err == nil { - if r.Match([]byte(event.Message)) { - include = true - break - } - } else { - alertLogger.Error(err, fmt.Sprintf("failed to compile inclusion regex: %s", inclusionRegex)) - } - } - if !include { - continue each_alert - } - } + w.WriteHeader(http.StatusAccepted) + } +} - // skip alert if the message matches a regex from the exclusion list - if len(alert.Spec.ExclusionList) > 0 { - for _, exclusionRegex := range alert.Spec.ExclusionList { - if r, err := regexp.Compile(exclusionRegex); err == nil { - if r.Match([]byte(event.Message)) { - continue each_alert - } - } else { - alertLogger.Error(err, fmt.Sprintf("failed to compile exclusion regex: %s", exclusionRegex)) - } - } - } +func (s *EventServer) getAllAlertsForEvent(ctx context.Context, event *eventv1.Event) ([]apiv1beta3.Alert, error) { + var allAlerts apiv1beta3.AlertList + err := s.kubeClient.List(ctx, &allAlerts) + if err != nil { + return nil, fmt.Errorf("failed listing alerts: %w", err) + } - // filter alerts by object and severity - for _, source := range alert.Spec.EventSources { - if source.Namespace == "" { - source.Namespace = alert.Namespace - } + return s.filterAlertsForEvent(ctx, allAlerts.Items, event), nil +} - if s.eventMatchesAlert(ctx, event, source, alert.Spec.EventSeverity) { - alerts = append(alerts, alert) - } - } +// filterAlertsForEvent filters a given set of alerts against a given event, +// checking if the event matches with any of the alert event sources and is +// allowed by the exclusion list. +func (s *EventServer) filterAlertsForEvent(ctx context.Context, alerts []apiv1beta3.Alert, event *eventv1.Event) []apiv1beta3.Alert { + logger := log.FromContext(ctx) + + results := make([]apiv1beta3.Alert, 0) + for i := range alerts { + alert := &alerts[i] + // Skip suspended alert. + if alert.Spec.Suspend { + continue } - if len(alerts) == 0 { - eventLogger.Info("Discarding event, no alerts found for the involved object") - w.WriteHeader(http.StatusAccepted) - return + alertLogger := logger.WithValues(alert.Kind, client.ObjectKeyFromObject(alert)) + ctx := log.IntoContext(ctx, alertLogger) + + // Check if the event matches any of the alert sources. + if !s.eventMatchesAlertSources(ctx, event, *alert) { + continue + } + // Check if the event message is allowed for the alert based on the + // inclusion list. + if !s.messageIsIncluded(ctx, event.Message, alert.Spec.InclusionList) { + continue + } + // Check if the event message is allowed for the alert based on the + // exclusion list. + if s.messageIsExcluded(ctx, event.Message, alert.Spec.ExclusionList) { + continue } + results = append(results, *alert) + } + return results +} - eventLogger.Info(fmt.Sprintf("Dispatching event: %s", event.Message)) +// eventMatchesAlertSources returns if a given event matches with any of the +// alert sources. +func (s *EventServer) eventMatchesAlertSources(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) bool { + for _, source := range alert.Spec.EventSources { + if source.Namespace == "" { + source.Namespace = alert.Namespace + } + if s.eventMatchesAlert(ctx, event, source, alert.Spec.EventSeverity) { + return true + } + } + return false +} - // dispatch notifications - for i, alert := range alerts { - alertLogger := eventLogger.WithValues("alert", client.ObjectKeyFromObject(&alerts[i])) - ctx := log.IntoContext(ctx, alertLogger) +// messageIsIncluded returns if the given message matches with the inclusion +// rules. +func (s *EventServer) messageIsIncluded(ctx context.Context, msg string, inclusionList []string) bool { + if len(inclusionList) == 0 { + return true + } - // verify if event comes from a different namespace - if s.noCrossNamespaceRefs && event.InvolvedObject.Namespace != alert.Namespace { - accessDenied := fmt.Errorf( - "alert '%s/%s' can't process event from '%s/%s/%s', cross-namespace references have been blocked", - alert.Namespace, alert.Name, event.InvolvedObject.Kind, event.InvolvedObject.Namespace, event.InvolvedObject.Name) - alertLogger.Error(accessDenied, "Discarding event, access denied to cross-namespace sources") - continue + for _, exp := range inclusionList { + if r, err := regexp.Compile(exp); err == nil { + if r.Match([]byte(msg)) { + return true } + } else { + // TODO: Record event on the respective Alert object. + log.FromContext(ctx).Error(err, fmt.Sprintf("failed to compile inclusion regex: %s", exp)) + } + } + return false +} - var provider apiv1beta2.Provider - providerName := types.NamespacedName{Namespace: alert.Namespace, Name: alert.Spec.ProviderRef.Name} +// messageIsExcluded returns if the given message matches with the exclusion +// rules. +func (s *EventServer) messageIsExcluded(ctx context.Context, msg string, exclusionList []string) bool { + if len(exclusionList) == 0 { + return false + } - err = s.kubeClient.Get(ctx, providerName, &provider) - if err != nil { - alertLogger.Error(err, "failed to read provider") - continue + for _, exp := range exclusionList { + if r, err := regexp.Compile(exp); err == nil { + if r.Match([]byte(msg)) { + return true } + } else { + // TODO: Record event on the respective Alert object. + log.FromContext(ctx).Error(err, fmt.Sprintf("failed to compile exclusion regex: %s", exp)) + } + } + return false +} - if provider.Spec.Suspend { - continue - } +// dispatchNotification constructs and sends notification from the given event +// and alert data. +func (s *EventServer) dispatchNotification(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) error { + sender, notification, token, timeout, err := s.getNotificationParams(ctx, event, alert) + if err != nil { + return err + } + // Skip when either sender or notification couldn't be created. + if sender == nil || notification == nil { + return nil + } - webhook := provider.Spec.Address - username := provider.Spec.Username - proxy := provider.Spec.Proxy - token := "" - password := "" - headers := make(map[string]string) - if provider.Spec.SecretRef != nil { - var secret corev1.Secret - secretName := types.NamespacedName{Namespace: alert.Namespace, Name: provider.Spec.SecretRef.Name} - - err = s.kubeClient.Get(ctx, secretName, &secret) - if err != nil { - alertLogger.Error(err, "failed to read secret") - continue - } - - if address, ok := secret.Data["address"]; ok { - webhook = string(address) - } - - if p, ok := secret.Data["password"]; ok { - password = string(p) - } - - if p, ok := secret.Data["proxy"]; ok { - proxy = string(p) - } - - if t, ok := secret.Data["token"]; ok { - token = string(t) - } - - if u, ok := secret.Data["username"]; ok { - username = string(u) - } - - if h, ok := secret.Data["headers"]; ok { - err := yaml.Unmarshal(h, &headers) - if err != nil { - alertLogger.Error(err, "failed to read headers from secret") - continue - } - } + go func(n notifier.Interface, e eventv1.Event) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := n.Post(ctx, e); err != nil { + maskedErrStr, maskErr := masktoken.MaskTokenFromString(err.Error(), token) + if maskErr != nil { + err = maskErr + } else { + err = errors.New(maskedErrStr) } + // TODO: Record failed event on the associated Alert object. + log.FromContext(ctx).Error(err, "failed to send notification") + } + }(sender, *notification) + + return nil +} + +// getNotificationParams constructs the notification parameters from the given +// event and alert, and returns a notifier, event, token and timeout for sending +// the notification. The returned event is a mutated form of the input event +// based on the alert configuration. +func (s *EventServer) getNotificationParams(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) (notifier.Interface, *eventv1.Event, string, time.Duration, error) { + // Check if event comes from a different namespace. + if s.noCrossNamespaceRefs && event.InvolvedObject.Namespace != alert.Namespace { + accessDenied := fmt.Errorf( + "alert '%s/%s' can't process event from '%s/%s/%s', cross-namespace references have been blocked", + alert.Namespace, alert.Name, event.InvolvedObject.Kind, event.InvolvedObject.Namespace, event.InvolvedObject.Name) + return nil, nil, "", 0, fmt.Errorf("discarding event, access denied to cross-namespace sources: %w", accessDenied) + } - var certPool *x509.CertPool - if provider.Spec.CertSecretRef != nil { - var secret corev1.Secret - secretName := types.NamespacedName{Namespace: alert.Namespace, Name: provider.Spec.CertSecretRef.Name} - - err = s.kubeClient.Get(ctx, secretName, &secret) - if err != nil { - alertLogger.Error(err, "failed to read cert secret") - continue - } - - switch secret.Type { - case corev1.SecretTypeOpaque, corev1.SecretTypeTLS, "": - default: - alertLogger.Error(nil, "cannot use secret '%s' to get TLS certificate: invalid secret type: '%s'", - secret.Name, secret.Type) - continue - } - - caFile, ok := secret.Data["ca.crt"] - if !ok { - caFile, ok = secret.Data["caFile"] - if !ok { - alertLogger.Error(nil, "no 'ca.crt' key found in secret '%s'", provider.Spec.CertSecretRef.Name) - continue - } - alertLogger.Info("warning: specifying CA cert via 'caFile' is deprecated, please use 'ca.crt' instead") - } - - certPool = x509.NewCertPool() - ok = certPool.AppendCertsFromPEM(caFile) - if !ok { - alertLogger.Error(nil, "could not append to cert pool") - continue - } + var provider apiv1beta3.Provider + providerName := types.NamespacedName{Namespace: alert.Namespace, Name: alert.Spec.ProviderRef.Name} + + err := s.kubeClient.Get(ctx, providerName, &provider) + if err != nil { + return nil, nil, "", 0, fmt.Errorf("failed to read provider: %w", err) + } + + // Skip if the provider is suspended. + if provider.Spec.Suspend { + return nil, nil, "", 0, nil + } + + sender, token, err := createNotifier(ctx, s.kubeClient, provider) + if err != nil { + return nil, nil, "", 0, fmt.Errorf("failed to initialize notifier for provider '%s': %w", provider.Name, err) + } + + notification := *event.DeepCopy() + s.enhanceEventWithAlertMetadata(ctx, ¬ification, alert) + + return sender, ¬ification, token, provider.GetTimeout(), nil +} + +// createNotifier returns a notifier.Interface for the given Provider. +func createNotifier(ctx context.Context, kubeClient client.Client, provider apiv1beta3.Provider) (notifier.Interface, string, error) { + logger := log.FromContext(ctx) + + webhook := provider.Spec.Address + username := provider.Spec.Username + proxy := provider.Spec.Proxy + token := "" + password := "" + headers := make(map[string]string) + if provider.Spec.SecretRef != nil { + var secret corev1.Secret + secretName := types.NamespacedName{Namespace: provider.Namespace, Name: provider.Spec.SecretRef.Name} + + err := kubeClient.Get(ctx, secretName, &secret) + if err != nil { + return nil, "", fmt.Errorf("failed to read secret: %w", err) + } + + if address, ok := secret.Data["address"]; ok { + webhook = string(address) + _, err := url.Parse(webhook) + if err != nil { + return nil, "", fmt.Errorf("invalid address in secret '%s': %w", webhook, err) } + } + + if p, ok := secret.Data["password"]; ok { + password = string(p) + } - if webhook == "" { - alertLogger.Error(nil, "provider has no address") - continue + if p, ok := secret.Data["proxy"]; ok { + proxy = string(p) + _, err := url.Parse(proxy) + if err != nil { + return nil, "", fmt.Errorf("invalid proxy in secret '%s': %w", proxy, err) } + } + + if t, ok := secret.Data["token"]; ok { + token = string(t) + } - factory := notifier.NewFactory(webhook, proxy, username, provider.Spec.Channel, token, headers, certPool, password, string(provider.UID)) - sender, err := factory.Notifier(provider.Spec.Type) + if u, ok := secret.Data["username"]; ok { + username = string(u) + } + + if h, ok := secret.Data["headers"]; ok { + err := yaml.Unmarshal(h, &headers) if err != nil { - alertLogger.Error(err, "failed to initialize provider") - continue + return nil, "", fmt.Errorf("failed to read headers from secret: %w", err) } + } + } + + var certPool *x509.CertPool + if provider.Spec.CertSecretRef != nil { + var secret corev1.Secret + secretName := types.NamespacedName{Namespace: provider.Namespace, Name: provider.Spec.CertSecretRef.Name} - notification := *event.DeepCopy() - s.enhanceEventWithAlertMetadata(ctx, ¬ification, alert) - - go func(n notifier.Interface, e eventv1.Event) { - ctx, cancel := context.WithTimeout(context.Background(), provider.GetTimeout()) - defer cancel() - ctx = log.IntoContext(ctx, alertLogger) - if err := n.Post(ctx, e); err != nil { - maskedErrStr, maskErr := masktoken.MaskTokenFromString(err.Error(), token) - if maskErr != nil { - err = maskErr - } else { - err = errors.New(maskedErrStr) - } - alertLogger.Error(err, "failed to send notification") - } - }(sender, notification) + err := kubeClient.Get(ctx, secretName, &secret) + if err != nil { + return nil, "", fmt.Errorf("failed to read cert secret: %w", err) } - w.WriteHeader(http.StatusAccepted) + switch secret.Type { + case corev1.SecretTypeOpaque, corev1.SecretTypeTLS, "": + default: + return nil, "", fmt.Errorf("cannot use Secret '%s' to get TLS certificate: invalid Secret type: '%s'", secret.Name, secret.Type) + } + + caFile, ok := secret.Data["ca.crt"] + if !ok { + // TODO: Drop support for old field in new API version. + caFile, ok = secret.Data["caFile"] + if !ok { + return nil, "", fmt.Errorf("no 'ca.crt' key found in Secret '%s'", secret.Name) + } + logger.Info("warning: specifying CA cert via 'caFile' is deprecated, please use 'ca.crt' instead") + } + + certPool = x509.NewCertPool() + ok = certPool.AppendCertsFromPEM(caFile) + if !ok { + return nil, "", fmt.Errorf("could not append to cert pool") + } } + + if webhook == "" { + return nil, "", fmt.Errorf("provider has no address") + } + + factory := notifier.NewFactory(webhook, proxy, username, provider.Spec.Channel, token, headers, certPool, password, string(provider.UID)) + sender, err := factory.Notifier(provider.Spec.Type) + if err != nil { + return nil, "", fmt.Errorf("failed to initialize notifier: %w", err) + } + return sender, token, nil } +// eventMatchesAlert returns if a given event matches with the given alert +// source configuration and severity. func (s *EventServer) eventMatchesAlert(ctx context.Context, event *eventv1.Event, source apiv1.CrossNamespaceObjectReference, severity string) bool { - alertLogger := log.FromContext(ctx) - - if event.InvolvedObject.Namespace == source.Namespace && event.InvolvedObject.Kind == source.Kind { - if event.Severity == severity || severity == eventv1.EventSeverityInfo { - labelMatch := true - if source.Name == "*" && source.MatchLabels != nil { - var obj metav1.PartialObjectMetadata - obj.SetGroupVersionKind(event.InvolvedObject.GroupVersionKind()) - obj.SetName(event.InvolvedObject.Name) - obj.SetNamespace(event.InvolvedObject.Namespace) - - if err := s.kubeClient.Get(ctx, types.NamespacedName{ - Namespace: event.InvolvedObject.Namespace, - Name: event.InvolvedObject.Name, - }, &obj); err != nil { - alertLogger.Error(err, "error getting the involved object") - } - - sel, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ - MatchLabels: source.MatchLabels, - }) - if err != nil { - alertLogger.Error(err, fmt.Sprintf("error using matchLabels from event source '%s'", source.Name)) - } - - labelMatch = sel.Matches(labels.Set(obj.GetLabels())) - } + logger := log.FromContext(ctx) - if source.Name == "*" && labelMatch || event.InvolvedObject.Name == source.Name { - return true - } - } + // No match if the event and source don't have the same namespace and kind. + if event.InvolvedObject.Namespace != source.Namespace || + event.InvolvedObject.Kind != source.Kind { + return false } - return false + // No match if the alert severity doesn't match the event severity and + // the alert severity isn't info. + if event.Severity != severity && severity != eventv1.EventSeverityInfo { + return false + } + + // No match if the source name isn't wildcard, and source and event names + // don't match. + if source.Name != "*" && source.Name != event.InvolvedObject.Name { + return false + } + + // Match if no match labels specified. + if source.MatchLabels == nil { + return true + } + + // Perform label selector matching. + var obj metav1.PartialObjectMetadata + obj.SetGroupVersionKind(event.InvolvedObject.GroupVersionKind()) + obj.SetName(event.InvolvedObject.Name) + obj.SetNamespace(event.InvolvedObject.Namespace) + + if err := s.kubeClient.Get(ctx, types.NamespacedName{ + Namespace: event.InvolvedObject.Namespace, + Name: event.InvolvedObject.Name, + }, &obj); err != nil { + logger.Error(err, "error getting the involved object") + } + + sel, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ + MatchLabels: source.MatchLabels, + }) + if err != nil { + logger.Error(err, fmt.Sprintf("error using matchLabels from event source '%s'", source.Name)) + } + + return sel.Matches(labels.Set(obj.GetLabels())) } -func (s *EventServer) enhanceEventWithAlertMetadata(ctx context.Context, event *eventv1.Event, alert apiv1beta2.Alert) { +// enhanceEventWithAlertMetadata enhances the event with Alert metadata. +func (s *EventServer) enhanceEventWithAlertMetadata(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) { meta := event.Metadata if meta == nil { meta = make(map[string]string) diff --git a/internal/server/event_handlers_test.go b/internal/server/event_handlers_test.go index 1f7a9c394..6e35c00a0 100644 --- a/internal/server/event_handlers_test.go +++ b/internal/server/event_handlers_test.go @@ -18,32 +18,955 @@ package server import ( "context" + "net/http" + "net/http/httptest" + "strconv" "testing" - "github.com/go-logr/logr" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + log "sigs.k8s.io/controller-runtime/pkg/log" - apiv1beta2 "github.com/fluxcd/notification-controller/api/v1beta2" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/fluxcd/pkg/apis/meta" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" ) +func TestFilterAlertsForEvent(t *testing.T) { + testNamespace := "foo-ns" + + testProvider := &apiv1beta3.Provider{} + testProvider.Name = "provider-foo" + testProvider.Namespace = testNamespace + testProvider.Spec = apiv1beta3.ProviderSpec{ + Type: "generic", + Address: "https://example.com", + } + + // Event involved object. + involvedObj := corev1.ObjectReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + Name: "foo", + Namespace: testNamespace, + } + testEvent := &eventv1.Event{ + InvolvedObject: involvedObj, + Message: "some excluded message", + } + + tests := []struct { + name string + alertSpecs []apiv1beta3.AlertSpec + resultAlertCount int + }{ + { + name: "all match", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + }, + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "foo", + }, + }, + }, + }, + resultAlertCount: 2, + }, + { + name: "some suspended alerts", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + Suspend: true, + }, + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "foo", + }, + }, + }, + }, + resultAlertCount: 1, + }, + { + name: "alerts with inclusion list unmatch", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + InclusionList: []string{"some unmatch include"}, + }, + }, + resultAlertCount: 0, + }, + { + name: "alerts with inclusion list match", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + InclusionList: []string{"some unmatch include"}, + }, + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + InclusionList: []string{"some"}, + }, + }, + resultAlertCount: 1, + }, + { + name: "alerts with invalid inclusion rule", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + InclusionList: []string{"["}, + }, + }, + resultAlertCount: 0, + }, + { + name: "alerts with exclusion list match", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + }, + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "foo", + }, + }, + ExclusionList: []string{"excluded message"}, + }, + }, + resultAlertCount: 1, + }, + { + name: "alerts with invalid exclusion rule", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + }, + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "foo", + }, + }, + ExclusionList: []string{"["}, + }, + }, + resultAlertCount: 2, + }, + { + name: "alerts with inclusion and exclusion list match", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + }, + }, + }, + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "foo", + }, + }, + InclusionList: []string{"excluded message"}, + ExclusionList: []string{"excluded message"}, + }, + }, + resultAlertCount: 1, + }, + { + name: "event source NS is not overwritten by alert NS", + alertSpecs: []apiv1beta3.AlertSpec{ + { + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Kustomization", + Name: "*", + Namespace: "foo-bar", + }, + }, + }, + }, + resultAlertCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + alerts := []apiv1beta3.Alert{} + for i, alertSpec := range tt.alertSpecs { + // Add the default provider ref for this test. + alertSpec.ProviderRef = meta.LocalObjectReference{Name: testProvider.Name} + // Create new Alert with the spec. + alert := apiv1beta3.Alert{} + alert.Name = "test-alert-" + strconv.Itoa(i) + alert.Namespace = testNamespace + alert.Spec = alertSpec + alerts = append(alerts, alert) + } + + // Create fake objects and event server. + scheme := runtime.NewScheme() + g.Expect(apiv1beta3.AddToScheme(scheme)).ToNot(HaveOccurred()) + builder := fakeclient.NewClientBuilder().WithScheme(scheme) + builder.WithObjects(testProvider) + eventServer := EventServer{ + kubeClient: builder.Build(), + logger: log.Log, + } + + result := eventServer.filterAlertsForEvent(context.TODO(), alerts, testEvent) + g.Expect(len(result)).To(Equal(tt.resultAlertCount)) + }) + } +} + +func TestDispatchNotification(t *testing.T) { + testNamespace := "foo-ns" + + // Run test notification receiver server. + rcvServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer rcvServer.Close() + + testProvider := &apiv1beta3.Provider{} + testProvider.Name = "provider-foo" + testProvider.Namespace = testNamespace + testProvider.Spec = apiv1beta3.ProviderSpec{ + Type: "generic", + Address: rcvServer.URL, + } + + testAlert := &apiv1beta3.Alert{} + testAlert.Name = "alert-foo" + testAlert.Namespace = testNamespace + testAlert.Spec = apiv1beta3.AlertSpec{ + ProviderRef: meta.LocalObjectReference{Name: testProvider.Name}, + } + + // Event involved object. + involvedObj := corev1.ObjectReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + Name: "foo", + Namespace: testNamespace, + } + testEvent := &eventv1.Event{InvolvedObject: involvedObj} + + tests := []struct { + name string + providerNamespace string + providerSuspended bool + wantErr bool + }{ + { + name: "dispatch notification successfully", + }, + { + name: "provider in different namespace", + providerNamespace: "bar-ns", + wantErr: true, + }, + { + name: "provider suspended, skip", + providerSuspended: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + alert := testAlert.DeepCopy() + provider := testProvider.DeepCopy() + + // Override the alert and provider with test parameters. + if tt.providerNamespace != "" { + provider.Namespace = tt.providerNamespace + } + provider.Spec.Suspend = tt.providerSuspended + + // Create fake objects and event server. + scheme := runtime.NewScheme() + g.Expect(apiv1beta3.AddToScheme(scheme)).ToNot(HaveOccurred()) + g.Expect(corev1.AddToScheme(scheme)).ToNot(HaveOccurred()) + builder := fakeclient.NewClientBuilder().WithScheme(scheme) + builder.WithObjects(provider) + eventServer := EventServer{ + kubeClient: builder.Build(), + logger: log.Log, + } + + err := eventServer.dispatchNotification(context.TODO(), testEvent, *alert) + g.Expect(err != nil).To(Equal(tt.wantErr)) + }) + } +} + +func TestGetNotificationParams(t *testing.T) { + testNamespace := "foo-ns" + + // Provider secret. + testSecret := &corev1.Secret{} + testSecret.Name = "secret-foo" + testSecret.Namespace = testNamespace + + testProvider := &apiv1beta3.Provider{} + testProvider.Name = "provider-foo" + testProvider.Namespace = testNamespace + testProvider.Spec = apiv1beta3.ProviderSpec{ + Type: "generic", + Address: "https://example.com", + SecretRef: &meta.LocalObjectReference{Name: testSecret.Name}, + } + + testAlert := &apiv1beta3.Alert{} + testAlert.Name = "alert-foo" + testAlert.Namespace = testNamespace + testAlert.Spec = apiv1beta3.AlertSpec{ + ProviderRef: meta.LocalObjectReference{Name: testProvider.Name}, + } + + // Event involved object. + involvedObj := corev1.ObjectReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + Name: "foo", + Namespace: testNamespace, + } + testEvent := &eventv1.Event{InvolvedObject: involvedObj} + + tests := []struct { + name string + alertNamespace string + alertSummary string + alertEventMetadata map[string]string + providerNamespace string + providerSuspended bool + secretNamespace string + noCrossNSRefs bool + eventMetadata map[string]string + wantErr bool + }{ + { + name: "event src and alert in diff NS", + alertNamespace: "bar-ns", + providerNamespace: "bar-ns", + secretNamespace: "bar-ns", + }, + { + name: "event src and alert in diff NS with no cross NS refs", + alertNamespace: "bar-ns", + providerNamespace: "bar-ns", + noCrossNSRefs: true, + wantErr: true, + }, + { + name: "provider not found", + providerNamespace: "bar-ns", + wantErr: true, + }, + { + name: "provider secret in diff NS but provider suspended", + providerSuspended: true, + secretNamespace: "bar-ns", + }, + { + name: "provider secret in different NS, fail to create notifier", + secretNamespace: "bar-ns", + wantErr: true, + }, + { + name: "alert with summary, no event metadata", + alertSummary: "some summary text", + }, + { + name: "alert with summary, with event metadata", + alertSummary: "some summary text", + eventMetadata: map[string]string{ + "foo": "bar", + "summary": "baz", + }, + }, + { + name: "alert with event metadata", + alertEventMetadata: map[string]string{ + "aaa": "bbb", + "ccc": "ddd", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + alert := testAlert.DeepCopy() + provider := testProvider.DeepCopy() + secret := testSecret.DeepCopy() + event := testEvent.DeepCopy() + + // Override the alert, provider, secret and event with test + // parameters. + if tt.alertNamespace != "" { + alert.Namespace = tt.alertNamespace + } + if tt.alertSummary != "" { + alert.Spec.Summary = tt.alertSummary + } + if tt.alertEventMetadata != nil { + alert.Spec.EventMetadata = tt.alertEventMetadata + } + if tt.providerNamespace != "" { + provider.Namespace = tt.providerNamespace + } + provider.Spec.Suspend = tt.providerSuspended + if tt.secretNamespace != "" { + secret.Namespace = tt.secretNamespace + } + if tt.eventMetadata != nil { + event.Metadata = tt.eventMetadata + } + + // Create fake objects and event server. + scheme := runtime.NewScheme() + g.Expect(apiv1beta3.AddToScheme(scheme)).ToNot(HaveOccurred()) + g.Expect(corev1.AddToScheme(scheme)).ToNot(HaveOccurred()) + builder := fakeclient.NewClientBuilder().WithScheme(scheme) + builder.WithObjects(provider, secret) + eventServer := EventServer{ + kubeClient: builder.Build(), + logger: log.Log, + noCrossNamespaceRefs: tt.noCrossNSRefs, + } + + _, n, _, _, err := eventServer.getNotificationParams(context.TODO(), event, *alert) + g.Expect(err != nil).To(Equal(tt.wantErr)) + if tt.alertSummary != "" { + g.Expect(n.Metadata["summary"]).To(Equal(tt.alertSummary)) + } + // NOTE: This is performing simple check. Thorough test for event + // metadata is performed in TestEnhanceEventWithAlertMetadata. + if tt.alertEventMetadata != nil { + for k, v := range tt.alertEventMetadata { + g.Expect(n.Metadata).To(HaveKeyWithValue(k, v)) + } + } + }) + } +} + +func TestCreateNotifier(t *testing.T) { + secretName := "foo-secret" + certSecretName := "cert-secret" + tests := []struct { + name string + providerSpec *apiv1beta3.ProviderSpec + secretType corev1.SecretType + secretData map[string][]byte + certSecretData map[string][]byte + wantErr bool + }{ + { + name: "no address, no secret ref", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + }, + wantErr: true, + }, + { + name: "valid address, no secret ref", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + }, + }, + { + name: "reference to non-existing secret ref", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + SecretRef: &meta.LocalObjectReference{Name: "foo"}, + }, + wantErr: true, + }, + { + name: "reference to secret with valid address, proxy, headers", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + SecretRef: &meta.LocalObjectReference{Name: secretName}, + }, + secretData: map[string][]byte{ + "address": []byte("https://example.com"), + "proxy": []byte("https://exampleproxy.com"), + "headers": []byte(`foo: bar`), + }, + }, + { + name: "reference to secret with invalid address", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + SecretRef: &meta.LocalObjectReference{Name: secretName}, + }, + secretData: map[string][]byte{ + "address": []byte("https://example.com|"), + }, + wantErr: true, + }, + { + name: "reference to secret with invalid proxy", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + SecretRef: &meta.LocalObjectReference{Name: secretName}, + }, + secretData: map[string][]byte{ + "address": []byte("https://example.com"), + "proxy": []byte("https://exampleproxy.com|"), + }, + wantErr: true, + }, + { + name: "invalid headers in secret reference", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + SecretRef: &meta.LocalObjectReference{Name: secretName}, + }, + secretData: map[string][]byte{ + "address": []byte("https://example.com"), + "headers": []byte("foo"), + }, + wantErr: true, + }, + { + name: "invalid spec address overridden by valid secret ref address", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + SecretRef: &meta.LocalObjectReference{Name: secretName}, + Address: "https://example.com|", + }, + secretData: map[string][]byte{ + "address": []byte("https://example.com"), + }, + }, + { + name: "invalid spec proxy overridden by valid secret ref proxy", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + SecretRef: &meta.LocalObjectReference{Name: secretName}, + Proxy: "https://example.com|", + }, + secretData: map[string][]byte{ + "address": []byte("https://example.com"), + "proxy": []byte("https://example.com"), + }, + }, + { + name: "reference to unsupported cert secret type", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + CertSecretRef: &meta.LocalObjectReference{Name: certSecretName}, + }, + secretType: corev1.SecretTypeDockercfg, + certSecretData: map[string][]byte{ + "aaa": []byte("bbb"), + }, + wantErr: true, + }, + { + name: "reference to non-existing cert secret", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + CertSecretRef: &meta.LocalObjectReference{Name: "some-secret"}, + }, + wantErr: true, + }, + { + name: "reference to cert secret without cert data", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + CertSecretRef: &meta.LocalObjectReference{Name: certSecretName}, + }, + certSecretData: map[string][]byte{ + "aaa": []byte("bbb"), + }, + wantErr: true, + }, + { + name: "cert secret reference in ca.crt with valid CA", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + CertSecretRef: &meta.LocalObjectReference{Name: certSecretName}, + }, + certSecretData: map[string][]byte{ + // Based on https://pkg.go.dev/crypto/tls#X509KeyPair example. + "ca.crt": []byte(`-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc +6MF9+Yw1Yy0t +-----END CERTIFICATE-----`), + }, + }, + { + name: "cert secret reference in caFile with valid CA", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + CertSecretRef: &meta.LocalObjectReference{Name: certSecretName}, + }, + certSecretData: map[string][]byte{ + // Based on https://pkg.go.dev/crypto/tls#X509KeyPair example. + "caFile": []byte(`-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc +6MF9+Yw1Yy0t +-----END CERTIFICATE-----`), + }, + }, + { + name: "cert secret reference in both ca.crt and caFile", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + CertSecretRef: &meta.LocalObjectReference{Name: certSecretName}, + }, + certSecretData: map[string][]byte{ + // Based on https://pkg.go.dev/crypto/tls#X509KeyPair example. + "ca.crt": []byte(`-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc +6MF9+Yw1Yy0t +-----END CERTIFICATE-----`), + "caFile": []byte(`aaaaa`), // invalid + }, + }, + { + name: "cert secret reference with invalid CA", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "slack", + Address: "https://example.com", + CertSecretRef: &meta.LocalObjectReference{Name: certSecretName}, + }, + certSecretData: map[string][]byte{ + "ca.crt": []byte(`aaaaa`), + }, + wantErr: true, + }, + { + name: "unsupported provider", + providerSpec: &apiv1beta3.ProviderSpec{ + Type: "foo", + Address: "https://example.com", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Create fake objects and event server. + scheme := runtime.NewScheme() + g.Expect(apiv1beta3.AddToScheme(scheme)).ToNot(HaveOccurred()) + g.Expect(corev1.AddToScheme(scheme)).ToNot(HaveOccurred()) + builder := fakeclient.NewClientBuilder().WithScheme(scheme) + if tt.secretData != nil { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName}, + Data: tt.secretData, + } + builder.WithObjects(secret) + } + if tt.certSecretData != nil { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: certSecretName}, + Type: tt.secretType, + Data: tt.certSecretData, + } + builder.WithObjects(secret) + } + provider := apiv1beta3.Provider{Spec: *tt.providerSpec} + + _, _, err := createNotifier(context.TODO(), builder.Build(), provider) + g.Expect(err != nil).To(Equal(tt.wantErr)) + }) + } +} + +func TestEventMatchesAlert(t *testing.T) { + testNamespace := "foo-ns" + involvedObj := corev1.ObjectReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + Name: "foo", + Namespace: testNamespace, + } + + tests := []struct { + name string + event *eventv1.Event + source apiv1.CrossNamespaceObjectReference + severity string + resourcesFile string + wantResult bool + }{ + { + name: "source and event namespace mismatch", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "*", + Namespace: "test-ns", + }, + severity: "info", + wantResult: false, + }, + { + name: "source and event kind mismatch", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "GitRepository", + Name: "*", + Namespace: testNamespace, + }, + severity: "info", + wantResult: false, + }, + { + name: "event and alert severity mismatch, alert severity error", + event: &eventv1.Event{ + InvolvedObject: involvedObj, + Severity: "info", + }, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "*", + Namespace: testNamespace, + }, + severity: "error", + wantResult: false, + }, + { + name: "event and alert severity mismatch, alert severity info", + event: &eventv1.Event{ + InvolvedObject: involvedObj, + Severity: "error", + }, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "*", + Namespace: testNamespace, + }, + severity: "info", + wantResult: true, + }, + { + name: "source with matching kind and namespace, any name", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "*", + Namespace: testNamespace, + }, + severity: "info", + wantResult: true, + }, + { + name: "source with matching kind and namespace, unmatched name", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "bar", + Namespace: testNamespace, + }, + severity: "info", + wantResult: false, + }, + { + name: "source with matching kind and namespace, matched name", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "foo", + Namespace: testNamespace, + }, + severity: "info", + wantResult: true, + }, + { + name: "label selector match", + resourcesFile: "./testdata/kustomization.yaml", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "*", + Namespace: testNamespace, + MatchLabels: map[string]string{ + "app": "podinfo", + }, + }, + severity: "info", + wantResult: true, + }, + { + name: "label selector mismatch", + resourcesFile: "./testdata/kustomization.yaml", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "*", + Namespace: testNamespace, + MatchLabels: map[string]string{ + "aaa": "bbb", + }, + }, + severity: "info", + wantResult: false, + }, + { + name: "label selector, object not found", + event: &eventv1.Event{InvolvedObject: involvedObj}, + source: apiv1.CrossNamespaceObjectReference{ + Kind: "Kustomization", + Name: "*", + Namespace: testNamespace, + MatchLabels: map[string]string{ + "aaa": "bbb", + }, + }, + severity: "info", + wantResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + scheme := runtime.NewScheme() + g.Expect(apiv1beta3.AddToScheme(scheme)).ToNot(HaveOccurred()) + + builder := fakeclient.NewClientBuilder().WithScheme(scheme) + + // Create pre-existing resource from manifest file. + if tt.resourcesFile != "" { + obj, err := readManifest(tt.resourcesFile, testNamespace) + g.Expect(err).ToNot(HaveOccurred()) + builder.WithObjects(obj) + } + + eventServer := EventServer{ + kubeClient: builder.Build(), + logger: log.Log, + } + + result := eventServer.eventMatchesAlert(context.TODO(), tt.event, tt.source, tt.severity) + g.Expect(result).To(Equal(tt.wantResult)) + }) + } +} + func TestEnhanceEventWithAlertMetadata(t *testing.T) { - s := &EventServer{logger: logr.New(nil)} + s := &EventServer{logger: log.Log} for name, tt := range map[string]struct { event eventv1.Event - alert apiv1beta2.Alert + alert apiv1beta3.Alert expectedMetadata map[string]string }{ "empty metadata": { event: eventv1.Event{}, - alert: apiv1beta2.Alert{}, + alert: apiv1beta3.Alert{}, expectedMetadata: nil, }, "enhanced with summary": { event: eventv1.Event{}, - alert: apiv1beta2.Alert{ - Spec: apiv1beta2.AlertSpec{ + alert: apiv1beta3.Alert{ + Spec: apiv1beta3.AlertSpec{ Summary: "summary", }, }, @@ -57,8 +980,8 @@ func TestEnhanceEventWithAlertMetadata(t *testing.T) { "summary": "original summary", }, }, - alert: apiv1beta2.Alert{ - Spec: apiv1beta2.AlertSpec{ + alert: apiv1beta3.Alert{ + Spec: apiv1beta3.AlertSpec{ Summary: "summary", }, }, @@ -68,8 +991,8 @@ func TestEnhanceEventWithAlertMetadata(t *testing.T) { }, "enhanced with metadata": { event: eventv1.Event{}, - alert: apiv1beta2.Alert{ - Spec: apiv1beta2.AlertSpec{ + alert: apiv1beta3.Alert{ + Spec: apiv1beta3.AlertSpec{ EventMetadata: map[string]string{ "foo": "bar", }, @@ -85,8 +1008,8 @@ func TestEnhanceEventWithAlertMetadata(t *testing.T) { "foo": "baz", }, }, - alert: apiv1beta2.Alert{ - Spec: apiv1beta2.AlertSpec{ + alert: apiv1beta3.Alert{ + Spec: apiv1beta3.AlertSpec{ EventMetadata: map[string]string{ "foo": "bar", }, diff --git a/internal/server/event_server.go b/internal/server/event_server.go index aa9a0b38c..f21a490c5 100644 --- a/internal/server/event_server.go +++ b/internal/server/event_server.go @@ -39,6 +39,10 @@ import ( eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" ) +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=alerts,verbs=get;list +// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=providers,verbs=get + type eventContextKey struct{} // EventServer handles event POST requests diff --git a/internal/server/event_server_test.go b/internal/server/event_server_test.go index fc829e368..a40c77a83 100644 --- a/internal/server/event_server_test.go +++ b/internal/server/event_server_test.go @@ -21,29 +21,324 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" + "os" + "strconv" + "strings" "testing" "time" - "github.com/onsi/gomega" + . "github.com/onsi/gomega" "github.com/sethvargo/go-limiter/httplimit" "github.com/sethvargo/go-limiter/memorystore" + prommetrics "github.com/slok/go-http-metrics/metrics/prometheus" + "github.com/slok/go-http-metrics/middleware" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + log "sigs.k8s.io/controller-runtime/pkg/log" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/fluxcd/pkg/apis/meta" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" ) +func TestEventServer(t *testing.T) { + g := NewWithT(t) + + testNamespace := "foo-ns" + var req *http.Request + + // Run receiver server. + rcvServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req = r + w.WriteHeader(200) + })) + defer rcvServer.Close() + + provider := &apiv1beta3.Provider{} + provider.Name = "provider-foo" + provider.Namespace = testNamespace + provider.Spec = apiv1beta3.ProviderSpec{ + Type: "generic", + Address: rcvServer.URL, + } + + testAlert := &apiv1beta3.Alert{} + testAlert.Name = "alert-foo" + testAlert.Namespace = testNamespace + testAlert.Spec = apiv1beta3.AlertSpec{ + ProviderRef: meta.LocalObjectReference{Name: provider.Name}, + EventSeverity: "info", + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Bucket", + Name: "hyacinth", + Namespace: testNamespace, + }, + { + Kind: "Kustomization", + Name: "*", + }, + { + Kind: "GitRepository", + Name: "*", + MatchLabels: map[string]string{ + "app": "podinfo", + }, + }, + { + Kind: "Kustomization", + Name: "*", + Namespace: "test", + }, + }, + } + + // Create objects to be used as involved object in the test events. + repo1, err := readManifest("./testdata/repo.yaml", testNamespace) + g.Expect(err).ToNot(HaveOccurred()) + repo2, err := readManifest("./testdata/gitrepo2.yaml", testNamespace) + g.Expect(err).ToNot(HaveOccurred()) + + scheme := runtime.NewScheme() + g.Expect(apiv1beta3.AddToScheme(scheme)).ToNot(HaveOccurred()) + g.Expect(corev1.AddToScheme(scheme)).ToNot(HaveOccurred()) + + // Create a fake kube client with the above objects. + builder := fakeclient.NewClientBuilder().WithScheme(scheme) + builder.WithObjects(provider, repo1, repo2) + kclient := builder.Build() + + // Get a free port to run the event server at. + l, err := net.Listen("tcp", ":0") + g.Expect(err).ToNot(HaveOccurred()) + eventServerPort := strconv.Itoa(l.Addr().(*net.TCPAddr).Port) + g.Expect(l.Close()).ToNot(HaveOccurred()) + + // Create the event server to test. + eventMdlw := middleware.New(middleware.Config{ + Recorder: prommetrics.NewRecorder(prommetrics.Config{ + Prefix: "gotk_event", + }), + }) + store, err := memorystore.New(&memorystore.Config{ + Interval: 5 * time.Minute, + }) + if err != nil { + t.Fatalf("failed to create memory storage") + } + // eventServer := NewEventServer("127.0.0.1:"+eventServerPort, log.Log, builder.Build(), true) + eventServer := NewEventServer("127.0.0.1:"+eventServerPort, log.Log, kclient, true) + stopCh := make(chan struct{}) + go eventServer.ListenAndServe(stopCh, eventMdlw, store) + defer close(stopCh) + + // Create a base event which is copied and mutated in the test cases. + testEvent := eventv1.Event{ + InvolvedObject: corev1.ObjectReference{ + Kind: "Bucket", + Name: "hyacinth", + Namespace: testNamespace, + }, + Severity: "info", + Timestamp: metav1.Now(), + Message: "well that happened", + Reason: "event-happened", + ReportingController: "source-controller", + } + + tests := []struct { + name string + inclusionList []string + exclusionList []string + modifyEventFunc func(e *eventv1.Event) *eventv1.Event + forwarded bool + }{ + { + name: "forwards when source is a match", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { return e }, + forwarded: true, + }, + { + name: "drops event when source Kind does not match", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.InvolvedObject.Kind = "GitRepository" + return e + }, + forwarded: false, + }, + { + name: "drops event when source name does not match", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.InvolvedObject.Name = "slop" + return e + }, + forwarded: false, + }, + { + name: "drops event when source namespace does not match", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.InvolvedObject.Namespace = "all-buckets" + return e + }, + forwarded: false, + }, + { + name: "forwards when message matches inclusion list", + inclusionList: []string{"^included"}, + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.Message = "included" + return e + }, + forwarded: true, + }, + { + name: "drops when message does not match inclusion list", + inclusionList: []string{"^included"}, + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.Message = "not included" + return e + }, + forwarded: false, + }, + { + name: "drops event that is matched by exclusion", + exclusionList: []string{ + "doesnotoccur", // Not intended to match. + "excluded", + }, + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.Message = "this is excluded" + return e + }, + forwarded: false, + }, + { + name: "drops when message matches inclusion and exclusion list", + inclusionList: []string{"^included"}, + exclusionList: []string{"excluded"}, + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.Message = "included excluded" + return e + }, + forwarded: false, + }, + { + name: "forwards events when name wildcard is used", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.InvolvedObject.Kind = "Kustomization" + e.InvolvedObject.Name = "test" + e.InvolvedObject.Namespace = testNamespace + e.Message = "test" + return e + }, + forwarded: true, + }, + { + name: "forwards events when the label matches", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.InvolvedObject.Kind = "GitRepository" + e.InvolvedObject.Name = "podinfo" + e.InvolvedObject.APIVersion = "source.toolkit.fluxcd.io/v1" + e.InvolvedObject.Namespace = testNamespace + e.Message = "test" + return e + }, + forwarded: true, + }, + { + name: "drops events when the labels don't match", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.InvolvedObject.Kind = "GitRepository" + e.InvolvedObject.Name = "podinfo-two" + e.InvolvedObject.APIVersion = "source.toolkit.fluxcd.io/v1" + e.InvolvedObject.Namespace = testNamespace + e.Message = "test" + return e + }, + forwarded: false, + }, + { + name: "drops events for cross-namespace sources", + modifyEventFunc: func(e *eventv1.Event) *eventv1.Event { + e.InvolvedObject.Kind = "Kustomization" + e.InvolvedObject.Name = "test" + e.InvolvedObject.Namespace = "test" + e.Message = "test" + return e + }, + forwarded: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Reset the common receiver server request variable. + req = nil + + // Create the test alert. + alert := testAlert.DeepCopy() + if tt.inclusionList != nil { + alert.Spec.InclusionList = tt.inclusionList + } + if tt.exclusionList != nil { + alert.Spec.ExclusionList = tt.exclusionList + } + g.Expect(kclient.Create(context.TODO(), alert)).ToNot(HaveOccurred()) + defer func() { + g.Expect(kclient.Delete(context.TODO(), alert)) + }() + + // Create the test event. + event := testEvent.DeepCopy() + event = tt.modifyEventFunc(event) + + buf := &bytes.Buffer{} + g.Expect(json.NewEncoder(buf).Encode(event)).To(Succeed()) + res, err := http.Post("http://localhost:"+eventServerPort, "application/json", buf) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(res.StatusCode).To(Equal(http.StatusAccepted)) // Event server responds with 202 Accepted. + + if tt.forwarded { + g.Eventually(func() bool { + return req == nil + }, "2s", "0.1s").Should(BeFalse()) + } else { + // Check filtered requests. + // + // The event_server does forwarding in a goroutine, after + // responding to the POST of the event. This makes it + // difficult to know whether the provider has filtered the + // event, or just not run the goroutine yet. For now, use a + // timeout (and consistently so it can fail early). + g.Consistently(func() bool { + return req == nil + }, "1s", "0.1s").Should(BeTrue()) + } + }) + } +} + func TestEventKeyFunc(t *testing.T) { - g := gomega.NewGomegaWithT(t) + g := NewWithT(t) // Setup middleware store, err := memorystore.New(&memorystore.Config{ Interval: 10 * time.Minute, }) - g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(err).ShouldNot(HaveOccurred()) middleware, err := httplimit.NewMiddleware(store, eventKeyFunc) - g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(err).ShouldNot(HaveOccurred()) handler := middleware.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -58,7 +353,7 @@ func TestEventKeyFunc(t *testing.T) { }{ { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "1", Namespace: "1", @@ -69,7 +364,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "1", Namespace: "1", @@ -80,7 +375,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "1", Namespace: "1", @@ -91,7 +386,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "2", Namespace: "2", @@ -102,7 +397,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "3", Namespace: "3", @@ -113,7 +408,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "2", Namespace: "2", @@ -124,7 +419,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "4", Namespace: "4", @@ -138,7 +433,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "4", Namespace: "4", @@ -152,7 +447,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "4", Namespace: "4", @@ -166,7 +461,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "4", Namespace: "4", @@ -180,7 +475,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "4", Namespace: "4", @@ -194,7 +489,7 @@ func TestEventKeyFunc(t *testing.T) { }, { involvedObject: corev1.ObjectReference{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1beta1", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", Kind: "Kustomization", Name: "4", Namespace: "4", @@ -217,7 +512,7 @@ func TestEventKeyFunc(t *testing.T) { } cleanupMetadata(event) eventData, err := json.Marshal(event) - g.Expect(err).ShouldNot(gomega.HaveOccurred()) + g.Expect(err).ShouldNot(HaveOccurred()) res := httptest.NewRecorder() req := httptest.NewRequest("POST", "/", bytes.NewBuffer(eventData)) @@ -226,11 +521,78 @@ func TestEventKeyFunc(t *testing.T) { handler.ServeHTTP(res, reqWithEvent) if tt.rateLimit { - g.Expect(res.Code).Should(gomega.Equal(429)) - g.Expect(res.Header().Get("X-Ratelimit-Remaining")).Should(gomega.Equal("0")) + g.Expect(res.Code).Should(Equal(429)) + g.Expect(res.Header().Get("X-Ratelimit-Remaining")).Should(Equal("0")) } else { - g.Expect(res.Code).Should(gomega.Equal(200)) + g.Expect(res.Code).Should(Equal(200)) } }) } } + +func TestCleanupMetadata(t *testing.T) { + group := "kustomize.toolkit.fluxcd.io" + involvedObj := corev1.ObjectReference{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + Name: "foo", + Namespace: "foo-ns", + } + + tests := []struct { + name string + event *eventv1.Event + wantMeta map[string]string + }{ + { + name: "event with no metadata", + event: &eventv1.Event{InvolvedObject: involvedObj}, + wantMeta: map[string]string{}, + }, + { + name: "event with metadata", + event: &eventv1.Event{ + InvolvedObject: involvedObj, + Metadata: map[string]string{ + group + "/foo": "fooval", + group + "/bar": "barval", + group + "/" + eventv1.MetaChecksumKey: "aaaaa", + group + "/" + eventv1.MetaDigestKey: "bbbbbbbb", + "source.toolkit.fluxcd.io/baz": "bazval", + group + "/zzz": "zzzz", + group + "/aa/bb": "cc", + }, + }, + wantMeta: map[string]string{ + "foo": "fooval", + "bar": "barval", + "zzz": "zzzz", + "aa/bb": "cc", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + cleanupMetadata(tt.event) + + g.Expect(tt.event.Metadata).To(BeEquivalentTo(tt.wantMeta)) + }) + } +} + +func readManifest(path, namespace string) (*unstructured.Unstructured, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + yml := fmt.Sprintf(string(data), namespace) + reader := yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(string(yml)), 2048) + obj := &unstructured.Unstructured{} + if err := reader.Decode(obj); err != nil { + return nil, err + } + return obj, nil +} diff --git a/internal/server/testdata/gitrepo2.yaml b/internal/server/testdata/gitrepo2.yaml new file mode 100644 index 000000000..94995e0c2 --- /dev/null +++ b/internal/server/testdata/gitrepo2.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: podinfo-two + namespace: "%[1]s" + labels: + app: podinfo-two +spec: + interval: 1m + url: https://github.com/stefanprodan/podinfo + ref: + semver: 6.0.x diff --git a/internal/server/testdata/kustomization.yaml b/internal/server/testdata/kustomization.yaml new file mode 100644 index 000000000..601869678 --- /dev/null +++ b/internal/server/testdata/kustomization.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: foo + namespace: "%[1]s" + labels: + app: podinfo +spec: + interval: 1m diff --git a/internal/server/testdata/repo.yaml b/internal/server/testdata/repo.yaml new file mode 100644 index 000000000..1fa6334ea --- /dev/null +++ b/internal/server/testdata/repo.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: podinfo + namespace: "%[1]s" + labels: + app: podinfo +spec: + interval: 1m + url: https://github.com/stefanprodan/podinfo + ref: + semver: 6.0.x From 19a60e98ea67a5dc7b18a8832294044c71aec7dc Mon Sep 17 00:00:00 2001 From: Sunny Date: Tue, 6 Jun 2023 00:38:05 +0000 Subject: [PATCH 4/9] event handler: Add k8s events for Alerts Emit events in the event handler along with logs on the respective alert to make the message visible on the alert it belongs to. Signed-off-by: Sunny --- internal/server/event_handlers.go | 78 +++++++++++++++++--------- internal/server/event_handlers_test.go | 39 +++++++++---- internal/server/event_server.go | 5 +- internal/server/event_server_test.go | 5 +- main.go | 2 +- 5 files changed, 86 insertions(+), 43 deletions(-) diff --git a/internal/server/event_handlers.go b/internal/server/event_handlers.go index a919e4796..cd22053db 100644 --- a/internal/server/event_handlers.go +++ b/internal/server/event_handlers.go @@ -42,6 +42,14 @@ import ( "github.com/fluxcd/notification-controller/internal/notifier" ) +func involvedObjectString(o corev1.ObjectReference) string { + return fmt.Sprintf("%s/%s/%s", o.Kind, o.Namespace, o.Name) +} + +func crossNSObjectRefString(o apiv1.CrossNamespaceObjectReference) string { + return fmt.Sprintf("%s/%s/%s", o.Kind, o.Namespace, o.Name) +} + func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { event := r.Context().Value(eventContextKey{}).(*eventv1.Event) @@ -68,8 +76,10 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request) alert := &alerts[i] alertLogger := eventLogger.WithValues(alert.Kind, client.ObjectKeyFromObject(alert)) ctx := log.IntoContext(ctx, alertLogger) - if err := s.dispatchNotification(ctx, event, *alert); err != nil { + if err := s.dispatchNotification(ctx, event, alert); err != nil { alertLogger.Error(err, "failed to dispatch notification") + s.Eventf(alert, corev1.EventTypeWarning, "NotificationDispatchFailed", + "failed to dispatch notification for %s: %s", involvedObjectString(event.InvolvedObject), err) } } @@ -105,17 +115,17 @@ func (s *EventServer) filterAlertsForEvent(ctx context.Context, alerts []apiv1be ctx := log.IntoContext(ctx, alertLogger) // Check if the event matches any of the alert sources. - if !s.eventMatchesAlertSources(ctx, event, *alert) { + if !s.eventMatchesAlertSources(ctx, event, alert) { continue } // Check if the event message is allowed for the alert based on the // inclusion list. - if !s.messageIsIncluded(ctx, event.Message, alert.Spec.InclusionList) { + if !s.messageIsIncluded(ctx, event.Message, alert) { continue } // Check if the event message is allowed for the alert based on the // exclusion list. - if s.messageIsExcluded(ctx, event.Message, alert.Spec.ExclusionList) { + if s.messageIsExcluded(ctx, event.Message, alert) { continue } results = append(results, *alert) @@ -125,53 +135,55 @@ func (s *EventServer) filterAlertsForEvent(ctx context.Context, alerts []apiv1be // eventMatchesAlertSources returns if a given event matches with any of the // alert sources. -func (s *EventServer) eventMatchesAlertSources(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) bool { +func (s *EventServer) eventMatchesAlertSources(ctx context.Context, event *eventv1.Event, alert *apiv1beta3.Alert) bool { for _, source := range alert.Spec.EventSources { if source.Namespace == "" { source.Namespace = alert.Namespace } - if s.eventMatchesAlert(ctx, event, source, alert.Spec.EventSeverity) { + if s.eventMatchesAlertSource(ctx, event, alert, source) { return true } } return false } -// messageIsIncluded returns if the given message matches with the inclusion -// rules. -func (s *EventServer) messageIsIncluded(ctx context.Context, msg string, inclusionList []string) bool { - if len(inclusionList) == 0 { +// messageIsIncluded returns if the given message matches with the given alert's +// inclusion rules. +func (s *EventServer) messageIsIncluded(ctx context.Context, msg string, alert *apiv1beta3.Alert) bool { + if len(alert.Spec.InclusionList) == 0 { return true } - for _, exp := range inclusionList { + for _, exp := range alert.Spec.InclusionList { if r, err := regexp.Compile(exp); err == nil { if r.Match([]byte(msg)) { return true } } else { - // TODO: Record event on the respective Alert object. log.FromContext(ctx).Error(err, fmt.Sprintf("failed to compile inclusion regex: %s", exp)) + s.Eventf(alert, corev1.EventTypeWarning, + "InvalidConfig", "failed to compile inclusion regex: %s", exp) } } return false } -// messageIsExcluded returns if the given message matches with the exclusion -// rules. -func (s *EventServer) messageIsExcluded(ctx context.Context, msg string, exclusionList []string) bool { - if len(exclusionList) == 0 { +// messageIsExcluded returns if the given message matches with the given alert's +// exclusion rules. +func (s *EventServer) messageIsExcluded(ctx context.Context, msg string, alert *apiv1beta3.Alert) bool { + if len(alert.Spec.ExclusionList) == 0 { return false } - for _, exp := range exclusionList { + for _, exp := range alert.Spec.ExclusionList { if r, err := regexp.Compile(exp); err == nil { if r.Match([]byte(msg)) { return true } } else { - // TODO: Record event on the respective Alert object. log.FromContext(ctx).Error(err, fmt.Sprintf("failed to compile exclusion regex: %s", exp)) + s.Eventf(alert, corev1.EventTypeWarning, "InvalidConfig", + "failed to compile exclusion regex: %s", exp) } } return false @@ -179,7 +191,7 @@ func (s *EventServer) messageIsExcluded(ctx context.Context, msg string, exclusi // dispatchNotification constructs and sends notification from the given event // and alert data. -func (s *EventServer) dispatchNotification(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) error { +func (s *EventServer) dispatchNotification(ctx context.Context, event *eventv1.Event, alert *apiv1beta3.Alert) error { sender, notification, token, timeout, err := s.getNotificationParams(ctx, event, alert) if err != nil { return err @@ -199,8 +211,9 @@ func (s *EventServer) dispatchNotification(ctx context.Context, event *eventv1.E } else { err = errors.New(maskedErrStr) } - // TODO: Record failed event on the associated Alert object. log.FromContext(ctx).Error(err, "failed to send notification") + s.Eventf(alert, corev1.EventTypeWarning, "NotificationDispatchFailed", + "failed to send notification for %s: %s", involvedObjectString(event.InvolvedObject), err) } }(sender, *notification) @@ -211,12 +224,12 @@ func (s *EventServer) dispatchNotification(ctx context.Context, event *eventv1.E // event and alert, and returns a notifier, event, token and timeout for sending // the notification. The returned event is a mutated form of the input event // based on the alert configuration. -func (s *EventServer) getNotificationParams(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) (notifier.Interface, *eventv1.Event, string, time.Duration, error) { +func (s *EventServer) getNotificationParams(ctx context.Context, event *eventv1.Event, alert *apiv1beta3.Alert) (notifier.Interface, *eventv1.Event, string, time.Duration, error) { // Check if event comes from a different namespace. if s.noCrossNamespaceRefs && event.InvolvedObject.Namespace != alert.Namespace { accessDenied := fmt.Errorf( - "alert '%s/%s' can't process event from '%s/%s/%s', cross-namespace references have been blocked", - alert.Namespace, alert.Name, event.InvolvedObject.Kind, event.InvolvedObject.Namespace, event.InvolvedObject.Name) + "alert '%s/%s' can't process event from '%s', cross-namespace references have been blocked", + alert.Namespace, alert.Name, involvedObjectString(event.InvolvedObject)) return nil, nil, "", 0, fmt.Errorf("discarding event, access denied to cross-namespace sources: %w", accessDenied) } @@ -317,7 +330,7 @@ func createNotifier(ctx context.Context, kubeClient client.Client, provider apiv caFile, ok := secret.Data["ca.crt"] if !ok { - // TODO: Drop support for old field in new API version. + // TODO: Drop support for "caFile" field in v1 Provider API. caFile, ok = secret.Data["caFile"] if !ok { return nil, "", fmt.Errorf("no 'ca.crt' key found in Secret '%s'", secret.Name) @@ -344,9 +357,9 @@ func createNotifier(ctx context.Context, kubeClient client.Client, provider apiv return sender, token, nil } -// eventMatchesAlert returns if a given event matches with the given alert +// eventMatchesAlertSource returns if a given event matches with the given alert // source configuration and severity. -func (s *EventServer) eventMatchesAlert(ctx context.Context, event *eventv1.Event, source apiv1.CrossNamespaceObjectReference, severity string) bool { +func (s *EventServer) eventMatchesAlertSource(ctx context.Context, event *eventv1.Event, alert *apiv1beta3.Alert, source apiv1.CrossNamespaceObjectReference) bool { logger := log.FromContext(ctx) // No match if the event and source don't have the same namespace and kind. @@ -357,6 +370,7 @@ func (s *EventServer) eventMatchesAlert(ctx context.Context, event *eventv1.Even // No match if the alert severity doesn't match the event severity and // the alert severity isn't info. + severity := alert.Spec.EventSeverity if event.Severity != severity && severity != eventv1.EventSeverityInfo { return false } @@ -383,20 +397,26 @@ func (s *EventServer) eventMatchesAlert(ctx context.Context, event *eventv1.Even Name: event.InvolvedObject.Name, }, &obj); err != nil { logger.Error(err, "error getting the involved object") + s.Eventf(alert, corev1.EventTypeWarning, "SourceFetchFailed", + "error getting source object %s", involvedObjectString(event.InvolvedObject)) + return false } sel, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ MatchLabels: source.MatchLabels, }) if err != nil { - logger.Error(err, fmt.Sprintf("error using matchLabels from event source '%s'", source.Name)) + logger.Error(err, fmt.Sprintf("error using matchLabels from event source %s", crossNSObjectRefString(source))) + s.Eventf(alert, corev1.EventTypeWarning, "InvalidConfig", + "error using matchLabels from event source %s", crossNSObjectRefString(source)) + return false } return sel.Matches(labels.Set(obj.GetLabels())) } // enhanceEventWithAlertMetadata enhances the event with Alert metadata. -func (s *EventServer) enhanceEventWithAlertMetadata(ctx context.Context, event *eventv1.Event, alert apiv1beta3.Alert) { +func (s *EventServer) enhanceEventWithAlertMetadata(ctx context.Context, event *eventv1.Event, alert *apiv1beta3.Alert) { meta := event.Metadata if meta == nil { meta = make(map[string]string) @@ -408,6 +428,8 @@ func (s *EventServer) enhanceEventWithAlertMetadata(ctx context.Context, event * } else { log.FromContext(ctx). Info("metadata key found in the existing set of metadata", "key", key) + s.Eventf(alert, corev1.EventTypeWarning, "MetadataAppendFailed", + "metadata key found in the existing set of metadata for '%s' in %s", key, involvedObjectString(event.InvolvedObject)) } } diff --git a/internal/server/event_handlers_test.go b/internal/server/event_handlers_test.go index 6e35c00a0..e8a2a9eb9 100644 --- a/internal/server/event_handlers_test.go +++ b/internal/server/event_handlers_test.go @@ -27,6 +27,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" log "sigs.k8s.io/controller-runtime/pkg/log" @@ -273,8 +274,9 @@ func TestFilterAlertsForEvent(t *testing.T) { builder := fakeclient.NewClientBuilder().WithScheme(scheme) builder.WithObjects(testProvider) eventServer := EventServer{ - kubeClient: builder.Build(), - logger: log.Log, + kubeClient: builder.Build(), + logger: log.Log, + EventRecorder: record.NewFakeRecorder(32), } result := eventServer.filterAlertsForEvent(context.TODO(), alerts, testEvent) @@ -356,11 +358,12 @@ func TestDispatchNotification(t *testing.T) { builder := fakeclient.NewClientBuilder().WithScheme(scheme) builder.WithObjects(provider) eventServer := EventServer{ - kubeClient: builder.Build(), - logger: log.Log, + kubeClient: builder.Build(), + logger: log.Log, + EventRecorder: record.NewFakeRecorder(32), } - err := eventServer.dispatchNotification(context.TODO(), testEvent, *alert) + err := eventServer.dispatchNotification(context.TODO(), testEvent, alert) g.Expect(err != nil).To(Equal(tt.wantErr)) }) } @@ -501,9 +504,10 @@ func TestGetNotificationParams(t *testing.T) { kubeClient: builder.Build(), logger: log.Log, noCrossNamespaceRefs: tt.noCrossNSRefs, + EventRecorder: record.NewFakeRecorder(32), } - _, n, _, _, err := eventServer.getNotificationParams(context.TODO(), event, *alert) + _, n, _, _, err := eventServer.getNotificationParams(context.TODO(), event, alert) g.Expect(err != nil).To(Equal(tt.wantErr)) if tt.alertSummary != "" { g.Expect(n.Metadata["summary"]).To(Equal(tt.alertSummary)) @@ -940,18 +944,31 @@ func TestEventMatchesAlert(t *testing.T) { } eventServer := EventServer{ - kubeClient: builder.Build(), - logger: log.Log, + kubeClient: builder.Build(), + logger: log.Log, + EventRecorder: record.NewFakeRecorder(32), + } + alert := &apiv1beta3.Alert{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alert", + Namespace: "test-ns", + }, + Spec: apiv1beta3.AlertSpec{ + EventSeverity: tt.severity, + }, } - result := eventServer.eventMatchesAlert(context.TODO(), tt.event, tt.source, tt.severity) + result := eventServer.eventMatchesAlertSource(context.TODO(), tt.event, alert, tt.source) g.Expect(result).To(Equal(tt.wantResult)) }) } } func TestEnhanceEventWithAlertMetadata(t *testing.T) { - s := &EventServer{logger: log.Log} + s := &EventServer{ + logger: log.Log, + EventRecorder: record.NewFakeRecorder(32), + } for name, tt := range map[string]struct { event eventv1.Event @@ -1023,7 +1040,7 @@ func TestEnhanceEventWithAlertMetadata(t *testing.T) { t.Run(name, func(t *testing.T) { g := NewGomegaWithT(t) - s.enhanceEventWithAlertMetadata(context.Background(), &tt.event, tt.alert) + s.enhanceEventWithAlertMetadata(context.Background(), &tt.event, &tt.alert) g.Expect(tt.event.Metadata).To(BeEquivalentTo(tt.expectedMetadata)) }) } diff --git a/internal/server/event_server.go b/internal/server/event_server.go index f21a490c5..5ec4704e8 100644 --- a/internal/server/event_server.go +++ b/internal/server/event_server.go @@ -33,6 +33,7 @@ import ( "github.com/sethvargo/go-limiter/httplimit" "github.com/slok/go-http-metrics/middleware" "github.com/slok/go-http-metrics/middleware/std" + kuberecorder "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -51,14 +52,16 @@ type EventServer struct { logger logr.Logger kubeClient client.Client noCrossNamespaceRefs bool + kuberecorder.EventRecorder } // NewEventServer returns an HTTP server that handles events -func NewEventServer(port string, logger logr.Logger, kubeClient client.Client, noCrossNamespaceRefs bool) *EventServer { +func NewEventServer(port string, logger logr.Logger, kubeClient client.Client, eventRecorder kuberecorder.EventRecorder, noCrossNamespaceRefs bool) *EventServer { return &EventServer{ port: port, logger: logger.WithName("event-server"), kubeClient: kubeClient, + EventRecorder: eventRecorder, noCrossNamespaceRefs: noCrossNamespaceRefs, } } diff --git a/internal/server/event_server_test.go b/internal/server/event_server_test.go index a40c77a83..9aec6ace0 100644 --- a/internal/server/event_server_test.go +++ b/internal/server/event_server_test.go @@ -40,6 +40,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/tools/record" fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" log "sigs.k8s.io/controller-runtime/pkg/log" @@ -135,8 +136,8 @@ func TestEventServer(t *testing.T) { if err != nil { t.Fatalf("failed to create memory storage") } - // eventServer := NewEventServer("127.0.0.1:"+eventServerPort, log.Log, builder.Build(), true) - eventServer := NewEventServer("127.0.0.1:"+eventServerPort, log.Log, kclient, true) + eventServer := NewEventServer("127.0.0.1:"+eventServerPort, + log.Log, kclient, record.NewFakeRecorder(32), true) stopCh := make(chan struct{}) go eventServer.ListenAndServe(stopCh, eventMdlw, store) defer close(stopCh) diff --git a/main.go b/main.go index d10660e52..5c32e090e 100644 --- a/main.go +++ b/main.go @@ -202,7 +202,7 @@ func main() { Registry: crtlmetrics.Registry, }), }) - eventServer := server.NewEventServer(eventsAddr, ctrl.Log, mgr.GetClient(), aclOptions.NoCrossNamespaceRefs) + eventServer := server.NewEventServer(eventsAddr, ctrl.Log, mgr.GetClient(), mgr.GetEventRecorderFor(controllerName), aclOptions.NoCrossNamespaceRefs) go eventServer.ListenAndServe(ctx.Done(), eventMdlw, store) setupLog.Info("starting webhook receiver server", "addr", receiverAddr) From 84e1d74e32d0f312a4e5b5cdd446bfe212316fd3 Mon Sep 17 00:00:00 2001 From: Sunny Date: Mon, 12 Jun 2023 22:19:16 +0000 Subject: [PATCH 5/9] Add alert and provider reconcilers for migration Add new Alert and Provider reconcilers to perform migration to static objects. The new Alert and Provider APIs don't contain any status. When the existing Alerts and Providers are queries using the new API client, the status would be dropped. A subsequent write of the object to update the object in api-server will migrate the objects to the new version and drop the status. For the stale finalizers on the objects, the new reconcilers ensure that the finalizers get removed. Signed-off-by: Sunny --- config/rbac/role.yaml | 11 ++ internal/controller/alert_controller.go | 94 ++++++++++ internal/controller/alert_controller_test.go | 121 +++++++++++++ internal/controller/finalizer_predicate.go | 50 ++++++ .../controller/finalizer_predicate_test.go | 165 ++++++++++++++++++ internal/controller/provider_controller.go | 96 ++++++++++ .../controller/provider_controller_test.go | 119 +++++++++++++ internal/controller/suite_test.go | 18 ++ main.go | 18 ++ 9 files changed, 692 insertions(+) create mode 100644 internal/controller/alert_controller.go create mode 100644 internal/controller/alert_controller_test.go create mode 100644 internal/controller/finalizer_predicate.go create mode 100644 internal/controller/finalizer_predicate_test.go create mode 100644 internal/controller/provider_controller.go create mode 100644 internal/controller/provider_controller_test.go diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index ff6c89722..cd7f85a95 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -40,14 +40,25 @@ rules: resources: - alerts verbs: + - create + - delete - get - list + - patch + - update + - watch - apiGroups: - notification.toolkit.fluxcd.io resources: - providers verbs: + - create + - delete - get + - list + - patch + - update + - watch - apiGroups: - notification.toolkit.fluxcd.io resources: diff --git a/internal/controller/alert_controller.go b/internal/controller/alert_controller.go new file mode 100644 index 000000000..360dd8923 --- /dev/null +++ b/internal/controller/alert_controller.go @@ -0,0 +1,94 @@ +/* +Copyright 2023 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 controller + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + kuberecorder "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" + "github.com/fluxcd/pkg/runtime/patch" +) + +// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=alerts,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// AlertReconciler reconciles an Alert object to migrate it to static Alert. +type AlertReconciler struct { + client.Client + kuberecorder.EventRecorder + + ControllerName string +} + +func (r *AlertReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&apiv1beta3.Alert{}, builder.WithPredicates(finalizerPredicate{})). + Complete(r) +} + +func (r *AlertReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { + log := ctrl.LoggerFrom(ctx) + + obj := &apiv1beta3.Alert{} + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Early return if no migration is needed. + if !controllerutil.ContainsFinalizer(obj, apiv1.NotificationFinalizer) { + return ctrl.Result{}, nil + } + + // Examine if the object is under deletion. + var delete bool + if !obj.ObjectMeta.DeletionTimestamp.IsZero() { + delete = true + } + + // Skip if it's suspend and not being deleted. + if obj.Spec.Suspend && !delete { + log.Info("reconciliation is suspended for this object") + return ctrl.Result{}, nil + } + + patcher, err := patch.NewHelper(obj, r.Client) + if err != nil { + return ctrl.Result{}, err + } + + defer func() { + if err := patcher.Patch(ctx, obj); err != nil { + retErr = err + } + }() + + // Remove the notification-controller finalizer. + controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer) + + log.Info("removed finalizer from Alert to migrate to static Alert") + r.Event(obj, corev1.EventTypeNormal, "Migration", "removed finalizer from Alert to migrate to static Alert") + + return +} diff --git a/internal/controller/alert_controller_test.go b/internal/controller/alert_controller_test.go new file mode 100644 index 000000000..fdd8717a8 --- /dev/null +++ b/internal/controller/alert_controller_test.go @@ -0,0 +1,121 @@ +/* +Copyright 2023 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 controller + +import ( + "fmt" + "testing" + "time" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/patch" + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" +) + +func TestAlertReconciler(t *testing.T) { + g := NewWithT(t) + + timeout := 10 * time.Second + + testns, err := testEnv.CreateNamespace(ctx, "alert-test") + g.Expect(err).ToNot(HaveOccurred()) + + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(ctx, testns)).ToNot(HaveOccurred()) + }) + + alert := &apiv1beta3.Alert{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("alert-%s", randStringRunes(5)), + Namespace: testns.Name, + }, + } + alertKey := client.ObjectKeyFromObject(alert) + + // Remove finalizer at create. + + alert.ObjectMeta.Finalizers = append(alert.ObjectMeta.Finalizers, "foo.bar", apiv1.NotificationFinalizer) + alert.Spec = apiv1beta3.AlertSpec{ + ProviderRef: meta.LocalObjectReference{Name: "foo-provider"}, + EventSources: []apiv1.CrossNamespaceObjectReference{}, + } + g.Expect(testEnv.Create(ctx, alert)).ToNot(HaveOccurred()) + + g.Eventually(func() bool { + _ = testEnv.Get(ctx, alertKey, alert) + return !controllerutil.ContainsFinalizer(alert, apiv1.NotificationFinalizer) + }, timeout, time.Second).Should(BeTrue()) + + // Remove finalizer at update. + + patchHelper, err := patch.NewHelper(alert, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + alert.ObjectMeta.Finalizers = append(alert.ObjectMeta.Finalizers, apiv1.NotificationFinalizer) + g.Expect(patchHelper.Patch(ctx, alert)).ToNot(HaveOccurred()) + + g.Eventually(func() bool { + _ = testEnv.Get(ctx, alertKey, alert) + return !controllerutil.ContainsFinalizer(alert, apiv1.NotificationFinalizer) + }, timeout, time.Second).Should(BeTrue()) + + // Remove finalizer at delete. + + patchHelper, err = patch.NewHelper(alert, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + + // Suspend the alert to prevent finalizer from getting removed. + // Ensure only flux finalizer is set to allow the object to be garbage + // collected at the end. + // NOTE: Suspending and updating finalizers are done separately here as + // doing them in a single patch results in flaky test where the finalizer + // update doesn't gets registered with the kube-apiserver, resulting in + // timeout waiting for finalizer to appear on the object below. + alert.Spec.Suspend = true + g.Expect(patchHelper.Patch(ctx, alert)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + _ = k8sClient.Get(ctx, alertKey, alert) + return alert.Spec.Suspend == true + }, timeout).Should(BeTrue()) + + patchHelper, err = patch.NewHelper(alert, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + + // Add finalizer and verify that finalizer exists on the object using a live + // client. + alert.ObjectMeta.Finalizers = []string{apiv1.NotificationFinalizer} + g.Expect(patchHelper.Patch(ctx, alert)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + _ = k8sClient.Get(ctx, alertKey, alert) + return controllerutil.ContainsFinalizer(alert, apiv1.NotificationFinalizer) + }, timeout).Should(BeTrue()) + + // Delete the object and verify. + g.Expect(testEnv.Delete(ctx, alert)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + if err := testEnv.Get(ctx, alertKey, alert); err != nil { + return apierrors.IsNotFound(err) + } + return false + }, timeout).Should(BeTrue()) +} diff --git a/internal/controller/finalizer_predicate.go b/internal/controller/finalizer_predicate.go new file mode 100644 index 000000000..a9a6ea943 --- /dev/null +++ b/internal/controller/finalizer_predicate.go @@ -0,0 +1,50 @@ +/* +Copyright 2023 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 controller + +import ( + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" +) + +// finalizerPredicate implements predicate functions to allow events for objects +// that have the flux finalizer. +type finalizerPredicate struct { + predicate.Funcs +} + +// Create allows events for objects with flux finalizer that have beed created. +func (finalizerPredicate) Create(e event.CreateEvent) bool { + return controllerutil.ContainsFinalizer(e.Object, apiv1.NotificationFinalizer) +} + +// Update allows events for objects with flux finalizer that have been updated. +func (finalizerPredicate) Update(e event.UpdateEvent) bool { + if e.ObjectNew == nil { + return false + } + return controllerutil.ContainsFinalizer(e.ObjectNew, apiv1.NotificationFinalizer) +} + +// Delete allows events for objects with flux finalizer that have been marked +// for deletion. +func (finalizerPredicate) Delete(e event.DeleteEvent) bool { + return controllerutil.ContainsFinalizer(e.Object, apiv1.NotificationFinalizer) +} diff --git a/internal/controller/finalizer_predicate_test.go b/internal/controller/finalizer_predicate_test.go new file mode 100644 index 000000000..a86557d47 --- /dev/null +++ b/internal/controller/finalizer_predicate_test.go @@ -0,0 +1,165 @@ +/* +Copyright 2023 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 controller + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" +) + +func getAlertWithFinalizers(finalizers []string) *apiv1beta3.Alert { + return &apiv1beta3.Alert{ + ObjectMeta: metav1.ObjectMeta{ + Finalizers: finalizers, + }, + } +} + +func TestFinalizerPredicateCreate(t *testing.T) { + tests := []struct { + name string + object client.Object + want bool + }{ + { + name: "no finalizer", + object: getAlertWithFinalizers([]string{}), + want: false, + }, + { + name: "no flux finalizer", + object: getAlertWithFinalizers([]string{"foo.bar", "baz.bar"}), + want: false, + }, + { + name: "has flux finalizer", + object: getAlertWithFinalizers([]string{"foo.bar", apiv1.NotificationFinalizer, "baz.bar"}), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + event := event.CreateEvent{ + Object: tt.object, + } + + mp := finalizerPredicate{} + g.Expect(mp.Create(event)).To(Equal(tt.want)) + }) + } +} + +func TestFinalizerPredicateDelete(t *testing.T) { + tests := []struct { + name string + object client.Object + want bool + }{ + { + name: "no finalizer", + object: getAlertWithFinalizers([]string{}), + want: false, + }, + { + name: "no flux finalizer", + object: getAlertWithFinalizers([]string{"foo.bar", "baz.bar"}), + want: false, + }, + { + name: "has flux finalizer", + object: getAlertWithFinalizers([]string{"foo.bar", apiv1.NotificationFinalizer, "baz.bar"}), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + event := event.DeleteEvent{ + Object: tt.object, + } + + mp := finalizerPredicate{} + g.Expect(mp.Delete(event)).To(Equal(tt.want)) + }) + } +} + +func TestFinalizerPredicateUpdate(t *testing.T) { + tests := []struct { + name string + oldObject client.Object + newObject client.Object + want bool + }{ + { + name: "no new object", + oldObject: getAlertWithFinalizers([]string{apiv1.NotificationFinalizer}), + newObject: nil, + want: false, + }, + { + name: "no old object, new object without flux finalizer", + oldObject: nil, + newObject: getAlertWithFinalizers([]string{"foo.bar"}), + want: false, + }, + { + name: "no old object, new object with flux finalizer", + oldObject: nil, + newObject: getAlertWithFinalizers([]string{apiv1.NotificationFinalizer}), + want: true, + }, + { + name: "old and new objects with flux finalizer", + oldObject: getAlertWithFinalizers([]string{"foo.bar", apiv1.NotificationFinalizer, "baz.bar"}), + newObject: getAlertWithFinalizers([]string{"foo.bar", apiv1.NotificationFinalizer, "baz.bar"}), + want: true, + }, + { + name: "old object with flux finalizer, new object without", + oldObject: getAlertWithFinalizers([]string{"foo.bar", apiv1.NotificationFinalizer, "baz.bar"}), + newObject: getAlertWithFinalizers([]string{"foo.bar", "baz.bar"}), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + event := event.UpdateEvent{ + ObjectOld: tt.oldObject, + ObjectNew: tt.newObject, + } + + mp := finalizerPredicate{} + g.Expect(mp.Update(event)).To(Equal(tt.want)) + }) + } +} diff --git a/internal/controller/provider_controller.go b/internal/controller/provider_controller.go new file mode 100644 index 000000000..fee2e0db8 --- /dev/null +++ b/internal/controller/provider_controller.go @@ -0,0 +1,96 @@ +/* +Copyright 2023 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 controller + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + kuberecorder "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" + "github.com/fluxcd/pkg/runtime/patch" +) + +// +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=providers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// ProviderReconciler reconciles a Provider object to migrate it to static +// Provider. +type ProviderReconciler struct { + client.Client + kuberecorder.EventRecorder + + ControllerName string +} + +func (r *ProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&apiv1beta3.Provider{}, builder.WithPredicates(finalizerPredicate{})). + Complete(r) +} + +func (r *ProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { + log := ctrl.LoggerFrom(ctx) + + obj := &apiv1beta3.Provider{} + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Early return if no migration is needed. + if !controllerutil.ContainsFinalizer(obj, apiv1.NotificationFinalizer) { + return ctrl.Result{}, nil + } + + // Examine if the object is under deletion. + var delete bool + if !obj.ObjectMeta.DeletionTimestamp.IsZero() { + delete = true + } + + // Skip if it's suspend and not being deleted. + if obj.Spec.Suspend && !delete { + log.Info("reconciliation is suspended for this object") + return ctrl.Result{}, nil + } + + patcher, err := patch.NewHelper(obj, r.Client) + if err != nil { + return ctrl.Result{}, err + } + + defer func() { + if err := patcher.Patch(ctx, obj); err != nil { + retErr = err + } + }() + + // Remove the notification-controller finalizer. + controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer) + + log.Info("removed finalizer from Provider to migrate to static Provider") + r.Event(obj, corev1.EventTypeNormal, "Migration", "removed finalizer from Provider to migrate to static Provider") + + return +} diff --git a/internal/controller/provider_controller_test.go b/internal/controller/provider_controller_test.go new file mode 100644 index 000000000..3a25e9246 --- /dev/null +++ b/internal/controller/provider_controller_test.go @@ -0,0 +1,119 @@ +/* +Copyright 2023 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 controller + +import ( + "fmt" + "testing" + "time" + + "github.com/fluxcd/pkg/runtime/patch" + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + apiv1 "github.com/fluxcd/notification-controller/api/v1" + apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3" +) + +func TestProviderReconciler(t *testing.T) { + g := NewWithT(t) + + timeout := 10 * time.Second + + testns, err := testEnv.CreateNamespace(ctx, "provider-test") + g.Expect(err).ToNot(HaveOccurred()) + + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(ctx, testns)).ToNot(HaveOccurred()) + }) + + provider := &apiv1beta3.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("provider-%s", randStringRunes(5)), + Namespace: testns.Name, + }, + } + providerKey := client.ObjectKeyFromObject(provider) + + // Remove finalizer at create. + + provider.ObjectMeta.Finalizers = append(provider.ObjectMeta.Finalizers, "foo.bar", apiv1.NotificationFinalizer) + provider.Spec = apiv1beta3.ProviderSpec{ + Type: "slack", + } + g.Expect(testEnv.Create(ctx, provider)).ToNot(HaveOccurred()) + + g.Eventually(func() bool { + _ = testEnv.Get(ctx, providerKey, provider) + return !controllerutil.ContainsFinalizer(provider, apiv1.NotificationFinalizer) + }, timeout, time.Second).Should(BeTrue()) + + // Remove finalizer at update. + + patchHelper, err := patch.NewHelper(provider, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + provider.ObjectMeta.Finalizers = append(provider.ObjectMeta.Finalizers, apiv1.NotificationFinalizer) + g.Expect(patchHelper.Patch(ctx, provider)).ToNot(HaveOccurred()) + + g.Eventually(func() bool { + _ = testEnv.Get(ctx, providerKey, provider) + return !controllerutil.ContainsFinalizer(provider, apiv1.NotificationFinalizer) + }, timeout, time.Second).Should(BeTrue()) + + // Remove finalizer at delete. + + patchHelper, err = patch.NewHelper(provider, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + + // Suspend the provider to prevent finalizer from getting removed. + // Ensure only flux finalizer is set to allow the object to be garbage + // collected at the end. + // NOTE: Suspending and updating finalizers are done separately here as + // doing them in a single patch results in flaky test where the finalizer + // update doesn't gets registered with the kube-apiserver, resulting in + // timeout waiting for finalizer to appear on the object below. + provider.Spec.Suspend = true + g.Expect(patchHelper.Patch(ctx, provider)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + _ = k8sClient.Get(ctx, providerKey, provider) + return provider.Spec.Suspend == true + }, timeout).Should(BeTrue()) + + patchHelper, err = patch.NewHelper(provider, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + + // Add finalizer and verify that finalizer exists on the object using a live + // client. + provider.ObjectMeta.Finalizers = []string{apiv1.NotificationFinalizer} + g.Expect(patchHelper.Patch(ctx, provider)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + _ = k8sClient.Get(ctx, providerKey, provider) + return controllerutil.ContainsFinalizer(provider, apiv1.NotificationFinalizer) + }, timeout).Should(BeTrue()) + + // Delete the object and verify. + g.Expect(testEnv.Delete(ctx, provider)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + if err := testEnv.Get(ctx, providerKey, provider); err != nil { + return apierrors.IsNotFound(err) + } + return false + }, timeout).Should(BeTrue()) +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index e7d18f9d8..a26a3b428 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -43,6 +43,7 @@ import ( apiv1 "github.com/fluxcd/notification-controller/api/v1" apiv1b2 "github.com/fluxcd/notification-controller/api/v1beta2" + apiv1b3 "github.com/fluxcd/notification-controller/api/v1beta3" // +kubebuilder:scaffold:imports ) @@ -57,6 +58,7 @@ func TestMain(m *testing.M) { var err error utilruntime.Must(apiv1.AddToScheme(scheme.Scheme)) utilruntime.Must(apiv1b2.AddToScheme(scheme.Scheme)) + utilruntime.Must(apiv1b3.AddToScheme(scheme.Scheme)) testEnv = testenv.New(testenv.WithCRDPath( filepath.Join("..", "..", "config", "crd", "bases"), @@ -70,6 +72,22 @@ func TestMain(m *testing.M) { controllerName := "notification-controller" testMetricsH := controller.NewMetrics(testEnv, metrics.MustMakeRecorder(), apiv1.NotificationFinalizer) + if err := (&AlertReconciler{ + Client: testEnv, + ControllerName: controllerName, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + }).SetupWithManager(testEnv); err != nil { + panic(fmt.Sprintf("Failed to start AlertReconciler: %v", err)) + } + + if err := (&ProviderReconciler{ + Client: testEnv, + ControllerName: controllerName, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + }).SetupWithManager(testEnv); err != nil { + panic(fmt.Sprintf("Failed to start ProviderReconciler: %v", err)) + } + if err := (&ReceiverReconciler{ Client: testEnv, Metrics: testMetricsH, diff --git a/main.go b/main.go index 5c32e090e..5530ea7ba 100644 --- a/main.go +++ b/main.go @@ -173,6 +173,24 @@ func main() { metricsH := helper.NewMetrics(mgr, metrics.MustMakeRecorder(), apiv1.NotificationFinalizer) + if err = (&controller.ProviderReconciler{ + Client: mgr.GetClient(), + ControllerName: controllerName, + EventRecorder: mgr.GetEventRecorderFor(controllerName), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Provider") + os.Exit(1) + } + + if err = (&controller.AlertReconciler{ + Client: mgr.GetClient(), + ControllerName: controllerName, + EventRecorder: mgr.GetEventRecorderFor(controllerName), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Alert") + os.Exit(1) + } + if err = (&controller.ReceiverReconciler{ Client: mgr.GetClient(), ControllerName: controllerName, From b99de58dd3ac987b4f24c37e8c1d5f0c72418daf Mon Sep 17 00:00:00 2001 From: Sunny Date: Mon, 12 Jun 2023 22:53:11 +0000 Subject: [PATCH 6/9] workflows/e2e: Update status-defaults & smoke test The new alert and provider don't have any status. Remove any status related checks from workflows/e2e tests. Signed-off-by: Sunny --- .github/workflows/e2e.yaml | 5 +---- config/testdata/status-defaults/alert.yaml | 5 ----- config/testdata/status-defaults/provider.yaml | 4 ---- 3 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 config/testdata/status-defaults/alert.yaml delete mode 100644 config/testdata/status-defaults/provider.yaml diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 8bc4906d5..a5d943c0f 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -71,7 +71,7 @@ jobs: - name: Run default status test run: | kubectl apply -f config/testdata/status-defaults - for crd in alert provider receiver ; do + for crd in receiver ; do RESULT=$(kubectl get ${crd} status-defaults -o go-template={{.status}}) EXPECTED='map[observedGeneration:-1]' if [ "${RESULT}" != "${EXPECTED}" ] ; then @@ -88,9 +88,6 @@ jobs: - name: Run smoke tests run: | kubectl -n notification-system apply -f ./config/samples - kubectl -n notification-system wait provider/slack-provider-sample --for=condition=ready --timeout=1m - kubectl -n notification-system wait provider/generic-provider-sample --for=condition=ready --timeout=1m - kubectl -n notification-system wait alert/alert-sample --for=condition=ready --timeout=1m kubectl -n notification-system wait receiver/receiver-sample --for=condition=ready --timeout=1m - name: Logs run: | diff --git a/config/testdata/status-defaults/alert.yaml b/config/testdata/status-defaults/alert.yaml deleted file mode 100644 index 40bec30ad..000000000 --- a/config/testdata/status-defaults/alert.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -apiVersion: notification.toolkit.fluxcd.io/v1beta1 -kind: Alert -metadata: - name: status-defaults diff --git a/config/testdata/status-defaults/provider.yaml b/config/testdata/status-defaults/provider.yaml deleted file mode 100644 index 27e8372fe..000000000 --- a/config/testdata/status-defaults/provider.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: notification.toolkit.fluxcd.io/v1beta1 -kind: Provider -metadata: - name: status-defaults From b2e533af67c32e22c2e73d4d9432013106ab9301 Mon Sep 17 00:00:00 2001 From: Sunny Date: Thu, 15 Jun 2023 16:43:29 +0000 Subject: [PATCH 7/9] Add v1beta3 spec docs Signed-off-by: Sunny --- docs/spec/README.md | 1 + docs/spec/v1beta3/README.md | 13 + docs/spec/v1beta3/alerts.md | 250 ++++++ docs/spec/v1beta3/events.md | 64 ++ docs/spec/v1beta3/providers.md | 1528 ++++++++++++++++++++++++++++++++ 5 files changed, 1856 insertions(+) create mode 100644 docs/spec/v1beta3/README.md create mode 100644 docs/spec/v1beta3/alerts.md create mode 100644 docs/spec/v1beta3/events.md create mode 100644 docs/spec/v1beta3/providers.md diff --git a/docs/spec/README.md b/docs/spec/README.md index 43eb5d9f0..c112cb8a2 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -3,6 +3,7 @@ ## API Specification * [v1](v1/README.md) +* [v1beta3](v1beta3/README.md) * [v1beta2](v1beta2/README.md) * [v1beta1](v1beta1/README.md) * [v1alpha1](v1alpha1/README.md) diff --git a/docs/spec/v1beta3/README.md b/docs/spec/v1beta3/README.md new file mode 100644 index 000000000..dab9b003f --- /dev/null +++ b/docs/spec/v1beta3/README.md @@ -0,0 +1,13 @@ +# notification.toolkit.fluxcd.io/v1beta3 + +This is the v1beta3 API specification for defining events handling. + +## Specification + +* [Alerts](alerts.md) +* [Events](events.md) +* [Providers](providers.md) + +## Go Client + +* [github.com/fluxcd/pkg/runtime/events](https://pkg.go.dev/github.com/fluxcd/pkg/runtime/events) diff --git a/docs/spec/v1beta3/alerts.md b/docs/spec/v1beta3/alerts.md new file mode 100644 index 000000000..6932e195d --- /dev/null +++ b/docs/spec/v1beta3/alerts.md @@ -0,0 +1,250 @@ +# Alerts + + + +The `Alert` API defines how events are filtered by severity and involved object, and what provider to use for dispatching. + +## Example + +The following is an example of how to send alerts to Slack when Flux fails to reconcile the `flux-system` namespace. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Provider +metadata: + name: slack-bot + namespace: flux-system +spec: + type: slack + channel: general + address: https://slack.com/api/chat.postMessage + secretRef: + name: slack-bot-token +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Alert +metadata: + name: slack + namespace: flux-system +spec: + summary: "Cluster addons impacted in us-east-2" + providerRef: + name: slack-bot + eventSeverity: error + eventSources: + - kind: GitRepository + name: '*' + - kind: Kustomization + name: '*' +``` + +In the above example: + +- A Provider named `slack-bot` is created, indicated by the + `Provider.metadata.name` field. +- An Alert named `slack` is created, indicated by the + `Alert.metadata.name` field. +- The Alert references the `slack-bot` provider, indicated by the + `Alert.spec.providerRef` field. +- The notification-controller starts listening for events sent for + all GitRepositories and Kustomizations in the `flux-system` namespace. +- When an event with severity `error` is received, the controller posts + a message on Slack channel from `.spec.channel`, + containing the `summary` text and the reconciliation error. + +You can run this example by saving the manifests into `slack-alerts.yaml`. + +1. First create a secret with the Slack bot token: + + ```sh + kubectl -n flux-system create secret generic slack-bot-token --from-literal=token=xoxb-YOUR-TOKEN + ``` + +2. Apply the resources on the cluster: + + ```sh + kubectl -n flux-system apply --server-side -f slack-alerts.yaml + ``` + +## Writing an Alert spec + +As with all other Kubernetes config, an Alert needs `apiVersion`, +`kind`, and `metadata` fields. The name of an Alert object must be a +valid [DNS subdomain name](https://kubernetes.io/docs/concepts/overview/working-with-objects/names#dns-subdomain-names). + +An Alert also needs a +[`.spec` section](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status). + +### Summary + +`.spec.summary` is an optional field to specify a short description of the +impact and affected cluster. + +The summary max length can't be greater than 255 characters. + +### Provider reference + +`.spec.providerRef.name` is a required field to specify a name reference to a +[Provider](providers.md) in the same namespace as the Alert. + +### Event sources + +`.spec.eventSources` is a required field to specify a list of references to +Flux objects for which events are forwarded to the alert provider API. + +To select events issued by Flux objects, each entry in the `.spec.eventSources` list +must contain the following fields: + +- `kind` is the Flux Custom Resource Kind such as GitRepository, HelmRelease, Kustomization, etc. +- `name` is the Flux Custom Resource `.metadata.name`, or it can be set to the `*` wildcard. +- `namespace` is the Flux Custom Resource `.metadata.namespace`. + When not specified, the Alert `.metadata.namespace` is used instead. + +#### Select objects by name + +To select events issued by a single Flux object, set the `kind`, `name` and `namespace`: + +```yaml +eventSources: + - kind: GitRepository + name: webapp + namespace: apps +``` + +#### Select all objects in a namespace + +The `*` wildcard can be used to select events issued by all Flux objects of a particular `kind` in a `namespace`: + +```yaml +eventSources: + - kind: HelmRelease + name: '*' + namespace: apps +``` + +#### Select objects by label + +To select events issued by all Flux objects of a particular `kind` with specific `labels`: + +```yaml +eventSources: + - kind: HelmRelease + name: '*' + namespace: apps + matchLabels: + team: app-dev +``` + +#### Disable cross-namespace selectors + +**Note:** On multi-tenant clusters, platform admins can disable cross-namespace references by +starting the controller with the `--no-cross-namespace-refs=true` flag. +When this flag is set, alerts can only refer to event sources in the same namespace as the alert object, +preventing tenants from subscribing to another tenant's events. + +### Event metadata + +`.spec.eventMetadata` is an optional field for adding metadata to events dispatched by +the controller. This can be used for enhancing the context of the event. If a field +would override one already present on the original event as generated by the emitter, +then the override doesn't happen, i.e. the original value is preserved, and an info +log is printed. + +#### Example + +Add metadata fields to successful `HelmRelease` events: + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Alert +metadata: + name: +spec: + eventSources: + - kind: HelmRelease + name: '*' + inclusionList: + - ".*succeeded.*" + eventMetadata: + app.kubernetes.io/env: "production" + app.kubernetes.io/cluster: "my-cluster" + app.kubernetes.io/region: "us-east-1" +``` + +### Event severity + +`.spec.eventSeverity` is an optional field to filter events based on severity. When not specified, or +when the value is set to `info`, all events are forwarded to the alert provider API, including errors. +To receive alerts only on errors, set the field value to `error`. + +### Event exclusion + +`.spec.exclusionList` is an optional field to specify a list of regex expressions to filter +events based on message content. The event will be excluded if the message matches at least +one of the expressions in the list. + +#### Example + +Skip alerting if the message matches a [Go regex](https://golang.org/pkg/regexp/syntax) +from the exclusion list: + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Alert +metadata: + name: +spec: + eventSources: + - kind: GitRepository + name: '*' + exclusionList: + - "waiting.*socket" +``` + +The above definition will not send alerts for transient Git clone errors like: + +```text +unable to clone 'ssh://git@ssh.dev.azure.com/v3/...', error: SSH could not read data: Error waiting on socket +``` + +### Event inclusion + +`.spec.inclusionList` is an optional field to specify a list of regex expressions to filter +events based on message content. The event will be sent if the message matches at least one +of the expressions in the list, and discarded otherwise. If the message matches one of the +expressions in the inclusion list but also matches one of the expressions in the exclusion +list, then the event is still discarded (exclusion is stronger than inclusion). + +#### Example + +Alert if the message matches a [Go regex](https://golang.org/pkg/regexp/syntax) +from the inclusion list: + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Alert +metadata: + name: +spec: + eventSources: + - kind: HelmRelease + name: '*' + inclusionList: + - ".*succeeded.*" + exclusionList: + - ".*uninstall.*" + - ".*test.*" +``` + +The above definition will send alerts for successful Helm installs, upgrades and rollbacks, +but not uninstalls and tests. + +### Suspend + +`.spec.suspend` is an optional field to suspend the altering. +When set to `true`, the controller will stop processing events. +When the field is set to `false` or removed, it will resume. diff --git a/docs/spec/v1beta3/events.md b/docs/spec/v1beta3/events.md new file mode 100644 index 000000000..1eb048bd3 --- /dev/null +++ b/docs/spec/v1beta3/events.md @@ -0,0 +1,64 @@ +# Events + + + +The `Event` API defines the structure of the events issued by Flux controllers. + +Flux controllers use the [fluxcd/pkg/runtime/events](https://github.com/fluxcd/pkg/tree/main/runtime/events) +package to push events to the notification-controller API. + +## Example + +The following is an example of an event sent by kustomize-controller to report a reconciliation error. + +```json +{ + "involvedObject": { + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + "name": "webapp", + "namespace": "apps", + "uid": "7d0cdc51-ddcf-4743-b223-83ca5c699632" + }, + "metadata": { + "kustomize.toolkit.fluxcd.io/revision": "main/731f7eaddfb6af01cb2173e18f0f75b0ba780ef1" + }, + "severity":"error", + "reason": "ValidationFailed", + "message":"service/apps/webapp validation error: spec.type: Unsupported value: Ingress", + "reportingController":"kustomize-controller", + "timestamp":"2022-10-28T07:26:19Z" +} +``` + +In the above example: + +- An event is issued by kustomize-controller for a specific object, indicated in the + `involvedObject` field. +- The notification-controller receives the event and finds the [alerts](alerts.md) + that match the `involvedObject` and `severity` values. +- For all matching alerts, the controller posts the `message` and the source revision + extracted from `metadata` to the alert provider API. + +## Event structure + +The Go type that defines the event structure can be found in the +[fluxcd/pkg/apis/event/v1beta1](https://github.com/fluxcd/pkg/blob/main/apis/event/v1beta1/event.go) +package. + +## Rate limiting + +Events received by notification-controller are subject to rate limiting to reduce the +amount of duplicate alerts sent to external systems like Slack, Sentry, etc. + +Events are rate limited based on `involvedObject.name`, `involvedObject.namespace`, +`involvedObject.kind`, `message`, and `metadata`. +The interval of the rate limit is set by default to `5m` but can be configured +with the `--rate-limit-interval` controller flag. + +The event server exposes HTTP request metrics to track the amount of rate limited events. +The following promql will get the rate at which requests are rate limited: + +``` +rate(gotk_event_http_request_duration_seconds_count{code="429"}[30s]) +``` diff --git a/docs/spec/v1beta3/providers.md b/docs/spec/v1beta3/providers.md new file mode 100644 index 000000000..bc273005f --- /dev/null +++ b/docs/spec/v1beta3/providers.md @@ -0,0 +1,1528 @@ +# Providers + + + +The `Provider` API defines how events are encoded and where to send them. + +## Example + +The following is an example of how to send alerts to Slack when Flux fails to +install or upgrade [Flagger](https://github.com/fluxcd/flagger). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: slack-bot + namespace: flagger-system +spec: + type: slack + channel: general + address: https://slack.com/api/chat.postMessage + secretRef: + name: slack-bot-token +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Alert +metadata: + name: slack + namespace: flagger-system +spec: + summary: "Flagger impacted in us-east-2" + providerRef: + name: slack-bot + eventSeverity: error + eventSources: + - kind: HelmRepository + name: '*' + - kind: HelmRelease + name: '*' +``` + +In the above example: + +- A Provider named `slack-bot` is created, indicated by the + `Provider.metadata.name` field. +- An Alert named `slack` is created, indicated by the + `Alert.metadata.name` field. +- The Alert references the `slack-bot` provider, indicated by the + `Alert.spec.providerRef` field. +- The notification-controller starts listening for events sent for + all HelmRepositories and HelmReleases in the `flagger-system` namespace. +- When an event with severity `error` is received, the controller posts + a message on Slack containing the `summary` text and the Helm install or + upgrade error. +- The controller uses the Slack Bot token from the secret indicated by the + `Provider.spec.secretRef.name` to authenticate with the Slack API. + +You can run this example by saving the manifests into `slack-alerts.yaml`. + +1. First create a secret with the Slack bot token: + + ```sh + kubectl -n flagger-system create secret generic slack-bot-token --from-literal=token=xoxb-YOUR-TOKEN + ``` + +2. Apply the resources on the cluster: + + ```sh + kubectl -n flagger-system apply --server-side -f slack-alerts.yaml + ``` + +## Writing a provider spec + +As with all other Kubernetes config, a Provider needs `apiVersion`, +`kind`, and `metadata` fields. The name of an Alert object must be a +valid [DNS subdomain name](https://kubernetes.io/docs/concepts/overview/working-with-objects/names#dns-subdomain-names). + +A Provider also needs a +[`.spec` section](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status). + +### Type + +`.spec.type` is a required field that specifies which SaaS API to use. + +The supported alerting providers are: + +| Provider | Type | +|---------------------------------------------------------|------------------| +| [Generic webhook](#generic-webhook) | `generic` | +| [Generic webhook with HMAC](#generic-webhook-with-hmac) | `generic-hmac` | +| [Azure Event Hub](#azure-event-hub) | `azureeventhub` | +| [DataDog](#datadog) | `datadog` | +| [Discord](#discord) | `discord` | +| [GitHub dispatch](#github-dispatch) | `githubdispatch` | +| [Google Chat](#google-chat) | `googlechat` | +| [Google Pub/Sub](#google-pubsub) | `googlepubsub` | +| [Grafana](#grafana) | `grafana` | +| [Lark](#lark) | `lark` | +| [Matrix](#matrix) | `matrix` | +| [Microsoft Teams](#microsoft-teams) | `msteams` | +| [Opsgenie](#opsgenie) | `opsgenie` | +| [PagerDuty](#pagerduty) | `pagerduty` | +| [Prometheus Alertmanager](#prometheus-alertmanager) | `alertmanager` | +| [Rocket](#rocket) | `rocket` | +| [Sentry](#sentry) | `sentry` | +| [Slack](#slack) | `slack` | +| [Telegram](#telegram) | `telegram` | +| [WebEx](#webex) | `webex` | + +The supported providers for [Git commit status updates](#git-commit-status-updates) are: + +| Provider | Type | +|--------------------------------------------------------------|-------------------| +| [Azure DevOps](#azure-devops) | `azuredevops` | +| [Bitbucket](#bitbucket) | `bitbucket` | +| [Bitbucket Server/Data Center](#bitbucket-serverdata-center) | `bitbucketserver` | +| [GitHub](#github) | `github` | +| [GitLab](#gitlab) | `gitlab` | +| [Gitea](#gitea) | `gitea` | + +#### Alerting + +##### Generic webhook + +When `.spec.type` is set to `generic`, the controller will send an HTTP POST +request to the provided [Address](#address). + +The body of the request is a [JSON `Event` object](events.md#event-structure), +for example: + +```json +{ + "involvedObject": { + "apiVersion": "kustomize.toolkit.fluxcd.io/v1beta2", + "kind": "Kustomization", + "name": "webapp", + "namespace": "apps", + "uid": "7d0cdc51-ddcf-4743-b223-83ca5c699632" + }, + "metadata": { + "kustomize.toolkit.fluxcd.io/revision": "main/731f7eaddfb6af01cb2173e18f0f75b0ba780ef1" + }, + "severity":"error", + "reason": "ValidationFailed", + "message":"service/apps/webapp validation error: spec.type: Unsupported value: Ingress", + "reportingController":"kustomize-controller", + "reportingInstance":"kustomize-controller-7c7b47f5f-8bhrp", + "timestamp":"2022-10-28T07:26:19Z" +} +``` + +Where the `involvedObject` key contains the metadata from the object triggering +the event. + +The controller includes a `Gotk-Component` header in the request, which can be +used to identify the component which sent the event, e.g. `source-controller` +or `notification-controller`. + +``` +POST / HTTP/1.1 +Host: example.com +Accept-Encoding: gzip +Content-Length: 452 +Content-Type: application/json +Gotk-Component: kustomize-controller +User-Agent: Go-http-client/1.1 +``` + +You can add additional headers to the POST request using a [`headers` key in the +referenced Secret](#http-headers-example). + +##### Generic webhook with HMAC + +When `.spec.type` is set to `generic-hmac`, the controller will send an HTTP +POST request to the provided [Address](#address) for an [Event](events.md#event-structure), +while including an `X-Signature` HTTP header carrying the HMAC of the request +body. The inclusion of the header allows the receiver to verify the +authenticity and integrity of the request. + +The `X-Signature` header is calculated by generating an HMAC of the request +body using the [`token` key from the referenced Secret](#token-example). The +HTTP header value has the following format: + +``` +X-Signature: = +``` + +`` denotes the hash function used to generate the HMAC and +currently defaults to `sha256`, which may change in the future. `` is the +HMAC of the request body, encoded as a hexadecimal string. + +while `` is the hex-encoded HMAC value. + +The body of the request is a [JSON `Event` object](events.md#event-structure), +as described in the [Generic webhook](#generic-webhook) section. + +###### HMAC verification example + +The following example in Go shows how to verify the authenticity and integrity +of a request by using the X-Signature header. + +```go +func verifySignature(signature string, payload, key []byte) error { + sig := strings.Split(signature, "=") + + if len(sig) != 2 { + return fmt.Errorf("invalid signature value") + } + + var newF func() hash.Hash + switch sig[0] { + case "sha224": + newF = sha256.New224 + case "sha256": + newF = sha256.New + case "sha384": + newF = sha512.New384 + case "sha512": + newF = sha512.New + default: + return fmt.Errorf("unsupported signature algorithm %q", sig[0]) + } + + mac := hmac.New(newF, key) + if _, err := mac.Write(payload); err != nil { + return fmt.Errorf("failed to write payload to HMAC encoder: %w", err) + } + + sum := fmt.Sprintf("%x", mac.Sum(nil)) + if sum != sig[1] { + return fmt.Errorf("HMACs do not match: %#v != %#v", sum, sig[1]) + } + return nil +} + +func handleRequest(w http.ResponseWriter, r *http.Request) { + // Require a X-Signature header + if len(r.Header["X-Signature"]) == 0 { + http.Error(w, "missing X-Signature header", http.StatusBadRequest) + return + } + + // Read the request body with a limit of 1MB + lr := io.LimitReader(r.Body, 1<<20) + body, err := io.ReadAll(lr) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + + // Verify signature using the same token as the Secret referenced in + // Provider + key := []byte("") + if err := verifySignature(r.Header.Get("X-Signature"), body, key); err != nil { + http.Error(w, fmt.Sprintf("failed to verify HMAC signature: %s", err.Error()), http.StatusBadRequest) + return + } + + // Do something with the verified request body + // ... +} +``` + +##### Slack + +When `.spec.type` is set to `slack`, the controller will send a message for an +[Event](events.md#event-structure) to the provided Slack API [Address](#address). + +The Event will be formatted into a Slack message using an [Attachment](https://api.slack.com/reference/messaging/attachments), +with the metadata attached as fields, and the involved object as author. +The severity of the Event is used to set the color of the attachment. + +When a [Channel](#channel) is provided, it will be added as a [`channel` +field](https://api.slack.com/methods/chat.postMessage#arg_channel) to the API +payload. Otherwise, the further configuration of the [Address](#address) will +determine the channel. + +When [Username](#username) is set, this will be added as a [`username` +field](https://api.slack.com/methods/chat.postMessage#arg_username) to the +payload, defaulting to the name of the reporting controller. + +This Provider type supports the configuration of a [proxy URL](#https-proxy) +and/or [TLS certificates](#tls-certificates). + +###### Slack example + +To configure a Provider for Slack, we recommend using a Slack Bot App token which is +not attached to a specific Slack account. To obtain a token, please follow +[Slack's guide on creating an app](https://api.slack.com/authentication/basics#creating). + +Once you have obtained a token, [create a Secret containing the `token` +key](#token-example) and a `slack` Provider with the `address` set to +`https://slack.com/api/chat.postMessage`. + +Using this API endpoint, it is possible to send messages to multiple channels +by adding the integration to each channel. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: slack + namespace: default +spec: + type: slack + channel: general + address: https://slack.com/api/chat.postMessage + secretRef: + name: slack-token +--- +apiVersion: v1 +kind: Secret +metadata: + name: slack-token + namespace: default +stringData: + token: xoxb-1234567890-1234567890-1234567890-1234567890 +``` + +###### Slack (legacy) example + +To configure a Provider for Slack using the [legacy incoming webhook API](https://api.slack.com/messaging/webhooks), +create a Secret with the `address` set to `https://hooks.slack.com/services/...`, +and a `slack` Provider with a [Secret reference](#address-example). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: slack + namespace: default +spec: + type: slack + secretRef: + name: slack-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: slack-webhook + namespace: default +stringData: + address: "https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK" +``` + +##### Microsoft Teams + +When `.spec.type` is set to `msteams`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Microsoft Teams [Address](#address). + +The Event will be formatted into a Microsoft Teams +[connector message](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#example-of-connector-message), +with the metadata attached as facts, and the involved object as summary. +The severity of the Event is used to set the color of the message. + +This Provider type supports the configuration of a [proxy URL](#https-proxy) +and/or [TLS certificates](#tls-certificates), but lacks support for +configuring a [Channel](#channel). This can be configured during the +creation of the incoming webhook in Microsoft Teams. + +###### Microsoft Teams example + +To configure a Provider for Microsoft Teams, create a Secret with [the +`address`](#address-example) set to the [webhook URL](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook#create-incoming-webhooks-1), +and a `msteams` Provider with a [Secret reference](#address-example). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: msteams + namespace: default +spec: + type: msteams + secretRef: + name: msteams-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: msteams-webhook + namespace: default +stringData: + address: "https://xxx.webhook.office.com/..." +``` + +##### DataDog + +When `.spec.type` is set to `datadog`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided DataDog API [Address](#address). + +The Event will be formatted into a [DataDog Event](https://docs.datadoghq.com/api/latest/events/#post-an-event) and sent to the +API endpoint of the provided DataDog [Address](#address). + +This Provider type supports the configuration of a [proxy URL](#https-proxy) +and/or [TLS certificates](#tls-certificates). + +The metadata of the Event is included in the DataDog event as extra tags. + +###### DataDog example + +To configure a Provider for DataDog, create a Secret with [the `token`](#token-example) +set to a [DataDog API key](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys) +(not an application key!) and a `datadog` Provider with a [Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: datadog + namespace: default +spec: + type: datadog + address: https://api.datadoghq.com # DataDog Site US1 + secretRef: + name: datadog-secret +--- +apiVersion: v1 +kind: Secret +metadata: + name: datadog-secret + namespace: default +stringData: + token: +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta1 +kind: Alert +metadata: + name: datadog-info + namespace: default +spec: + eventSeverity: info + eventSources: + - kind: HelmRelease + name: "*" + providerRef: + name: datadog + eventMetadata: + env: my-k8s-cluster # example of adding a custom `env` tag to the event +``` + +##### Discord + +When `.spec.type` is set to `discord`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Discord [Address](#address). + +The Event will be formatted into a [Slack message](#slack) and send to the +`/slack` endpoint of the provided Discord [Address](#address). + +This Provider type supports the configuration of a [proxy URL](#https-proxy) +and/or [TLS certificates](#tls-certificates), but lacks support for +configuring a [Channel](#channel). This can be configured [during the creation +of the address](https://discord.com/developers/docs/resources/webhook#create-webhook) + +###### Discord example + +To configure a Provider for Discord, create a Secret with [the `address`](#address-example) +set to the [webhook URL](https://discord.com/developers/docs/resources/webhook#create-webhook), +and a `discord` Provider with a [Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: discord + namespace: default +spec: + type: discord + secretRef: + name: discord-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: discord-webhook + namespace: default +stringData: + address: "https://discord.com/api/webhooks/..." +``` + + +##### Sentry + +When `.spec.type` is set to `sentry`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Sentry [Address](#address). + +Depending on the `severity` of the Event, the controller will capture a [Sentry +Event](https://develop.sentry.dev/sdk/event-payloads/)for `error`, or [Sentry +Transaction Event](https://develop.sentry.dev/sdk/event-payloads/transaction/) +with a [Span](https://develop.sentry.dev/sdk/event-payloads/span/) for `info`. +The metadata of the Event is included as [`extra` data](https://develop.sentry.dev/sdk/event-payloads/#optional-attributes) +in the Sentry Event, or as [Span `tags`](https://develop.sentry.dev/sdk/event-payloads/span/#attributes). + +The Provider's [Channel](#channel) is used to set the `environment` on the +Sentry client. + +This Provider type supports the configuration of +[TLS certificates](#tls-certificates). + +###### Sentry example + +To configure a Provider for Sentry, create a Secret with [the `address`](#address-example) +set to a [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/), +and a `sentry` Provider with a [Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: sentry + namespace: default +spec: + type: sentry + channel: staging-env + secretRef: + name: sentry-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: sentry-webhook + namespace: default +stringData: + address: "https://....@sentry.io/12341234" +``` + +**Note:** The `sentry` Provider also sends traces for events with the severity +`info`. This can be disabled by setting the `spec.eventSeverity` field to +`error` on an `Alert`. + +##### Telegram + +When `.spec.type` is set to `telegram`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Telegram [Address](#address). + +The Event will be formatted into a message string, with the metadata attached +as a list of key-value pairs. + +The Provider's [Channel](#channel) is used to set the receiver of the message. +This can be a unique identifier (`-1234567890`) for the target chat, or +the username (`@username`) of the target channel. + +This Provider type does not support the configuration of a [proxy URL](#https-proxy) +or [TLS certificates](#tls-certificates). + +###### Telegram example + +To configure a Provider for Telegram, create a Secret with [the `token`](#token-example) +obtained from [the BotFather](https://core.telegram.org/bots#how-do-i-create-a-bot), +and a `telegram` Provider with a [Secret reference](#secret-reference), and the +`address` set to `https://api.telegram.org`. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: telegram + namespace: default +spec: + type: telegram + address: https://api.telegram.org + channel: "@fluxcd" # or "-1557265138" (channel id) + secretRef: + name: telegram-token +``` + +##### Matrix + +When `.spec.type` is set to `matrix`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Matrix [Address](#address). + +The Event will be formatted into a message string, with the metadata attached +as a list of key-value pairs, and send as a [`m.room.message` text event](https://spec.matrix.org/v1.3/client-server-api/#mroommessage) +to the provided Matrix [Address](#address). + +The Provider's [Channel](#channel) is used to set the receiver of the message +using a room identifier (`!1234567890:example.org`). + +This provider type does support the configuration of [TLS +certificates](#tls-certificates). + +###### Matrix example + +To configure a Provider for Matrix, create a Secret with [the `token`](#token-example) +obtained from [the Matrix endpoint](https://matrix.org/docs/guides/client-server-api#registration), +and a `matrix` Provider with a [Secret reference](#secret-reference). + +```yaml +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: matrix + namespace: default +spec: + type: matrix + address: https://matrix.org + channel: "!jezptmDwEeLapMLjOc:matrix.org" + secretRef: + name: matrix-token +``` + +##### Lark + +When `.spec.type` is set to `lark`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Lark [Address](#address). + +The Event will be formatted into a [Lark Message card](https://open.larksuite.com/document/ukTMukTMukTM/uczM3QjL3MzN04yNzcDN), +with the metadata written to the message string. + +This Provider type does not support the configuration of a [proxy URL](#https-proxy) +or [TLS certificates](#tls-certificates). + +###### Lark example + +To configure a Provider for Lark, create a Secret with [the `address`](#address-example) +obtained from [adding a bot to a group](https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/bot-v3/use-custom-bots-in-a-group#57181e84), +and a `lark` Provider with a [Secret reference](#secret-reference). + +```yaml +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: lark + namespace: default +spec: + type: lark + secretRef: + name: lark-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: lark-webhook + namespace: default +stringData: + address: "https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxx" +``` + +##### Rocket + +When `.spec.type` is set to `rocket`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Rocket [Address](#address). + +The Event will be formatted into a [Slack message](#slack) and send as a +payload the provided Rocket [Address](#address). + +This Provider type does support the configuration of a [proxy URL](#https-proxy) +and [TLS certificates](#tls-certificates). + +###### Rocket example + +To configure a Provider for Rocket, create a Secret with [the `address`](#address-example) +set to the Rocket [webhook URL](https://docs.rocket.chat/guides/administration/admin-panel/integrations#incoming-webhook-script), +and a `rocket` Provider with a [Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: rocket + namespace: default +spec: + type: rocket + secretRef: + name: rocket-webhook +``` + +##### Google Chat + +When `.spec.type` is set to `googlechat`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Google Chat [Address](#address). + +The Event will be formatted into a [Google Chat card message](https://developers.google.com/chat/api/reference/rest/v1/cards-v1), +with the metadata added as a list of [key-value pairs](https://developers.google.com/chat/api/reference/rest/v1/cards-v1#keyvalue) +in a [widget](https://developers.google.com/chat/api/reference/rest/v1/cards-v1#widgetmarkup). + +This Provider type does support the configuration of a [proxy URL](#https-proxy). + +###### Google Chat example + +To configure a Provider for Google Chat, create a Secret with [the `address`](#address-example) +set to the Google Chat [webhook URL](https://developers.google.com/chat/how-tos/webhooks#create_a_webhook), +and a `googlechat` Provider with a [Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: google + namespace: default +spec: + type: googlechat + secretRef: + name: google-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: google-webhook + namespace: default +stringData: + address: https://chat.googleapis.com/v1/spaces/... +``` + +##### Google Pub/Sub + +When `.spec.type` is set to `googlepubsub`, the controller will publish the payload of +an [Event](events.md#event-structure) on the Google Pub/Sub Topic ID provided in the +[Channel](#channel) field, which must exist in the GCP Project ID provided in the +[Address](#address) field. + +This Provider type can optionally use the [Secret reference](#secret-reference) to +authenticate on the Google Pub/Sub API by using [JSON credentials](https://cloud.google.com/iam/docs/service-account-creds#key-types). +The credentials must be specified on [the `token`](#token-example) field of the Secret. + +If no JSON credentials are specified, then the automatic authentication methods of +the Google libraries will take place, and therefore methods like Workload Identity +will be automatically attempted. + +The Google identity effectively used for publishing messages must have +[the required permissions](https://cloud.google.com/iam/docs/understanding-roles#pubsub.publisher) +on the Pub/Sub Topic. + +You can optionally add [attributes](https://cloud.google.com/pubsub/docs/samples/pubsub-publish-custom-attributes#pubsub_publish_custom_attributes-go) +to the Pub/Sub message using a [`headers` key in the referenced Secret](#http-headers-example). + +This Provider type does not support the configuration of a [proxy URL](#https-proxy) +or [TLS certificates](#tls-certificates). + +###### Google Pub/Sub with JSON Credentials and Custom Headers Example + +To configure a Provider for Google Pub/Sub authenticating with JSON credentials and +custom headers, create a Secret with [the `token`](#token-example) set to the +necessary JSON credentials, [the `headers`](#http-headers-example) field set to a +YAML string-to-string dictionary, and a `googlepubsub` Provider with the associated +[Secret reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: googlepubsub-provider + namespace: desired-namespace +spec: + type: googlepubsub + address: + channel: + secretRef: + name: googlepubsub-provider-creds +--- +apiVersion: v1 +kind: Secret +metadata: + name: googlepubsub-provider-creds + namespace: desired-namespace +stringData: + token: + headers: | + attr1-name: attr1-value + attr2-name: attr2-value +``` + +##### Opsgenie + +When `.spec.type` is set to `opsgenie`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Opsgenie [Address](#address). + +The Event will be formatted into a [Opsgenie alert](https://docs.opsgenie.com/docs/alert-api#section-create-alert-request), +with the metadata added to the [`details` field](https://docs.opsgenie.com/docs/alert-api#create-alert) +as a list of key-value pairs. + +This Provider type does support the configuration of a [proxy URL](#https-proxy) +and [TLS certificates](#tls-certificates). + +###### Opsgenie example + +To configure a Provider for Opsgenie, create a Secret with [the `token`](#token-example) +set to the Opsgenie [API token](https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/), +and a `opsgenie` Provider with a [Secret reference](#secret-reference) and the +`address` set to `https://api.opsgenie.com/v2/alerts`. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: opsgenie + namespace: default +spec: + type: opsgenie + address: https://api.opsgenie.com/v2/alerts + secretRef: + name: opsgenie-token +--- +apiVersion: v1 +kind: Secret +metadata: + name: opsgenie-token + namespace: default +stringData: + token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +##### PagerDuty + +When `.spec.type` is set to `pagerduty`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided PagerDuty [Address](#address). + +The Event will be formatted into an [Event API v2](https://developer.pagerduty.com/api-reference/368ae3d938c9e-send-an-event-to-pager-duty) payload, +triggering or resolving an incident depending on the event's `Severity`. + +The provider will also send [Change Events](https://developer.pagerduty.com/api-reference/95db350959c37-send-change-events-to-the-pager-duty-events-api) +for `info` level `Severity`, which will be displayed in the PagerDuty service's timeline to track changes. + +This Provider type supports the configuration of a [proxy URL](#https-proxy) +and [TLS certificates](#tls-certificates). + +The [Channel](#channel) is used to set the routing key to send the event to the appropriate integration. + +###### PagerDuty example + +To configure a Provider for Pagerduty, create a `pagerduty` Provider, +set `address` to the integration URL and `channel` set to +the integration key (also known as a routing key) for your [service](https://support.pagerduty.com/docs/services-and-integrations#create-a-generic-events-api-integration) +or [event orchestration](https://support.pagerduty.com/docs/event-orchestration). + +When adding an integration for a service on PagerDuty, it is recommended to use `Events API v2` integration. + +**Note**: PagerDuty does not support Change Events when sent to global integrations, such as event orchestration. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: pagerduty + namespace: default +spec: + type: pagerduty + address: https://events.pagerduty.com + channel: +``` +If you are sending to a service integration, it is recommended to set your Alert to filter to +only those sources you want to trigger an incident for that service. For example: + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Alert +metadata: + name: my-service-pagerduty + namespace: default +spec: + providerRef: + name: pagerduty + eventSources: + - kind: HelmRelease + name: my-service + namespace: default +``` + +##### Prometheus Alertmanager + +When `.spec.type` is set to `alertmanager`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Prometheus Alertmanager +[Address](#address). + +The Event will be formatted into a `firing` [Prometheus Alertmanager +alert](https://prometheus.io/docs/alerting/latest/notifications/#alert), +with the metadata added to the `labels` fields, and the `message` (and optional +`.metadata.summary`) added as annotations. + +In addition to the metadata from the Event, the following labels will be added: + +| Label | Description | +|-----------|------------------------------------------------------------------------------------------------------| +| alertname | The string Flux followed by the Kind and the reason for the event e.g `FluxKustomizationProgressing` | +| severity | The severity of the event (`error` or `info`) | +| timestamp | The timestamp of the event | +| reason | The machine readable reason for the objects transition into the current status | +| kind | The kind of the involved object associated with the event | +| name | The name of the involved object associated with the event | +| namespace | The namespace of the involved object associated with the event | + +This Provider type does support the configuration of a [proxy URL](#https-proxy) +and [TLS certificates](#tls-certificates). + +###### Prometheus Alertmanager example + +To configure a Provider for Prometheus Alertmanager, create a Secret with [the +`address`](#address-example) set to the Prometheus Alertmanager [HTTP API +URL](https://prometheus.io/docs/alerting/latest/https/#http-traffic) +including Basic Auth credentials, and a `alertmanager` Provider with a [Secret +reference](#secret-reference). + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: alertmanager + namespace: default +spec: + type: alertmanager + secretRef: + name: alertmanager-address +--- +apiVersion: v1 +kind: Secret +metadata: + name: alertmanager-address + namespace: default +stringData: + address: https://username:password@/api/v2/alerts/" +``` + +##### Webex + +When `.spec.type` is set to `webex`, the controller will send a payload for +an [Event](events.md#event-structure) to the provided Webex [Address](#address). + +The Event will be formatted into a message string, with the metadata attached +as a list of key-value pairs, and send as a [Webex message](https://developer.webex.com/docs/api/v1/messages/create-a-message). + +The [Channel](#channel) is used to set the ID of the room to send the message +to. + +This Provider type does support the configuration of a [proxy URL](#https-proxy) +and [TLS certificates](#tls-certificates). + +###### Webex example + +To configure a Provider for Webex, create a Secret with [the `token`](#token-example) +set to the Webex [access token](https://developer.webex.com/docs/api/getting-started#authentication), +and a `webex` Provider with a [Secret reference](#secret-reference) and the +`address` set to `https://webexapis.com/v1/messages`. + +**Note:** To be able to send messages to a Webex room, the bot needs to be +added to the room. Failing to do so will result in 404 errors, logged by the +controller. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: webex + namespace: default +spec: + type: webex + address: https://webexapis.com/v1/messages + channel: + secretRef: + name: webex-token +--- +apiVersion: v1 +kind: Secret +metadata: + name: webex-token + namespace: default +stringData: + token: +``` + +### Address + +`.spec.address` is an optional field that specifies the endpoint where the events are posted. +The meaning of endpoint here depends on the specific Provider type being used. +For the `generic` Provider for example this is an HTTP/S address. +For other Provider types this could be a project ID or a namespace. + +If the address contains sensitive information such as tokens or passwords, it is +recommended to store the address in the Kubernetes secret referenced by `.spec.secretRef.name`. +When the referenced Secret contains an `address` key, the `.spec.address` value is ignored. + +### Channel + +`.spec.channel` is an optional field that specifies the channel where the events are posted. + +### Username + +`.spec.username` is an optional field that specifies the username used to post +the events. Can be overwritten with a [Secret reference](#secret-reference). + +### Secret reference + +`.spec.secretRef.name` is an optional field to specify a name reference to a +Secret in the same namespace as the Provider, containing the authentication +credentials for the provider API. + +The Kubernetes secret can have any of the following keys: + +- `address` - overrides `.spec.address` +- `proxy` - overrides `.spec.proxy` +- `token` - used for authentication +- `username` - overrides `.spec.username` +- `headers` - HTTP headers values included in the POST request + +#### Address example + +For providers which embed tokens or other sensitive information in the URL, +the incoming webhooks address can be stored in the secret using the `address` key: + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: my-provider-url + namespace: default +stringData: + address: "https://webhook.example.com/token" +``` + +#### Token example + +For providers which require token based authentication, the API token +can be stored in the secret using the `token` key: + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: my-provider-auth + namespace: default +stringData: + token: "my-api-token" +``` + +#### HTTP headers example + +For providers which require specific HTTP headers to be attached to the POST request, +the headers can be set in the secret using the `headers` key: + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: my-provider-headers + namespace: default +stringData: + headers: | + Authorization: my-api-token + X-Forwarded-Proto: https +``` + +#### Proxy auth example + +Some networks need to use an authenticated proxy to access external services. +Therefore, the proxy address can be stored as a secret to hide parameters like the username and password: + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: my-provider-proxy + namespace: default +stringData: + proxy: "http://username:password@proxy_url:proxy_port" +``` + +### TLS certificates + +`.spec.certSecretRef` is an optional field to specify a name reference to a +Secret in the same namespace as the Provider, containing the TLS CA certificate. +The secret must be of type `kubernetes.io/tls` or `Opaque`. + +#### Example + +To enable notification-controller to communicate with a provider API over HTTPS +using a self-signed TLS certificate, set the `ca.crt` like so: + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: my-webhook + namespace: flagger-system +spec: + type: generic + address: https://my-webhook.internal + certSecretRef: + name: my-ca-crt +--- +apiVersion: v1 +kind: Secret +metadata: + name: my-ca-crt + namespace: default +type: kubernetes.io/tls # or Opaque +stringData: + ca.crt: | + <--- CA Key ---> +``` + +**Warning:** Support for the `caFile` key has been +deprecated. If you have any Secrets using this key, +the controller will log a deprecation warning. + +### HTTP/S proxy + +`.spec.proxy` is an optional field to specify an HTTP/S proxy address. + +If the proxy address contains sensitive information such as basic auth credentials, it is +recommended to store the proxy in the Kubernetes secret referenced by `.spec.secretRef.name`. +When the referenced Secret contains a `proxy` key, the `.spec.proxy` value is ignored. + +### Suspend + +`.spec.suspend` is an optional field to suspend the provider. +When set to `true`, the controller will stop sending events to this provider. +When the field is set to `false` or removed, it will resume. + +## Working with Providers + + +### Grafana + +To send notifications to [Grafana annotations API](https://grafana.com/docs/grafana/latest/http_api/annotations/), +enable the annotations on a Dashboard like so: + +- Annotations > Query > Enable Match any +- Annotations > Query > Tags (Add Tag: `flux`) + +If Grafana has authentication configured, create a Kubernetes Secret with the API token: + +```shell +kubectl create secret generic grafana-token \ +--from-literal=token= \ +``` + +Grafana can also use basic authorization to authenticate the requests, if both the token and +the username/password are set in the secret, then token takes precedence over`basic auth: + +```shell +kubectl create secret generic grafana-token \ +--from-literal=username= \ +--from-literal=password= +``` + +Create a provider of type `grafana` and reference the `grafana-token` secret: + +```yaml +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: grafana + namespace: default +spec: + type: grafana + address: https:///api/annotations + secretRef: + name: grafana-token +``` + +### GitHub dispatch + +The `githubdispatch` provider generates GitHub events of type +[`repository_dispatch`](https://docs.github.com/en/rest/reference/repos#create-a-repository-dispatch-event) +for the selected repository. The `repository_dispatch` events can be used to trigger GitHub Actions workflow. + +The request includes the `event_type` and `client_payload` fields: + +- `event_type` is generated from the involved object in the format `{Kind}/{Name}.{Namespace}`. +- `client_payload` contains the [Flux event](events.md). + +### Setting up the GitHub dispatch provider + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: github-dispatch + namespace: flux-system +spec: + type: githubdispatch + address: https://github.com/stefanprodan/podinfo + secretRef: + name: api-token +``` + +The `address` is the address of your repository where you want to send webhooks to trigger GitHub workflows. + +GitHub uses personal access tokens for authentication with its API: + +* [GitHub personal access token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) + +The provider requires a secret in the same format, with the personal access token as the value for the token key: + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-token + namespace: default +data: + token: +``` + +#### Setting up a GitHub workflow + +To trigger a GitHub Actions workflow when a Flux Kustomization finishes reconciling, +you need to set the event type for the repository_dispatch trigger to match the Flux object ID: + +```yaml +name: test-github-dispatch-provider +on: + repository_dispatch: + types: [Kustomization/podinfo.flux-system] +``` + +Assuming that we deploy all Flux kustomization resources in the same namespace, +it will be useful to have a unique kustomization resource name for each application. +This will allow you to use only `event_type` to trigger tests for the exact application. + +Let's say we have following folder structure for applications kustomization manifests: + +```bash +apps/ +├── app1 +│   └── overlays +│   ├── production +│   └── staging +└── app2 + └── overlays + ├── production + └── staging +``` + +You can then create a flux kustomization resource for the app to have unique `event_type` per app. +The kustomization manifest for app1/staging: + +```yaml +apiVersion: kustomize.toolkit.fluxcd.io/v1beta2 +kind: Kustomization +metadata: + name: app1 + namespace: flux-system +spec: + path: "./app1/staging" +``` + +You would also like to know from the notification which cluster is being used for deployment. +You can add the `spec.summary` field to the Flux alert configuration to mention the relevant cluster: + +```yaml +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Alert +metadata: + name: github-dispatch + namespace: flux-system +spec: + summary: "staging (us-west-2)" + providerRef: + name: github-dispatch + eventSeverity: info + eventSources: + - kind: Kustomization + name: 'podinfo' +``` + +Now you can the trigger tests in the GitHub workflow for app1 in a staging cluster when +the app1 resources defined in `./app1/staging/` are reconciled by Flux: + +```yaml +name: test-github-dispatch-provider +on: + repository_dispatch: + types: [Kustomization/podinfo.flux-system] +jobs: + run-tests-staging: + if: github.event.client_payload.metadata.summary == 'staging (us-west-2)' + runs-on: ubuntu-18.04 + steps: + - name: Run tests + run: echo "running tests.." +``` + +### Azure Event Hub + +The Azure Event Hub supports two authentication methods, [JWT](https://docs.microsoft.com/en-us/azure/event-hubs/authenticate-application) +and [SAS](https://docs.microsoft.com/en-us/azure/event-hubs/authorize-access-shared-access-signature) based. + +#### JWT based auth + +In JWT we use 3 input values. Channel, token and address. +We perform the following translation to match we the data we need to communicate with Azure Event Hub. + +- channel = Azure Event Hub namespace +- address = Azure Event Hub name +- token = JWT + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: azure + namespace: default +spec: + type: azureeventhub + address: + channel: + secretRef: + name: azure-token +--- +apiVersion: v1 +kind: Secret +metadata: + name: azure-token + namespace: default +stringData: + token: +``` + +The controller doesn't take any responsibility for the JWT token to be updated. +You need to use a secondary tool to make sure that the token in the secret is renewed. + +If you want to make a easy test assuming that you have setup a Azure Enterprise application and you called it +event-hub you can follow most of the bellow commands. You will need to provide the `client_secret` that you got +when generating the Azure Enterprise Application. + +```shell +export AZURE_CLIENT=$(az ad app list --filter "startswith(displayName,'event-hub')" --query '[].appId' |jq -r '.[0]') +export AZURE_SECRET='secret-client-secret-generated-at-creation' +export AZURE_TENANT=$(az account show -o tsv --query tenantId) + +curl -X GET --data 'grant_type=client_credentials' --data "client_id=$AZURE_CLIENT" --data "client_secret=$AZURE_SECRET" --data 'resource=https://eventhubs.azure.net' -H 'Content-Type: application/x-www-form-urlencoded' https://login.microsoftonline.com/$AZURE_TENANT/oauth2/token |jq .access_token +``` + +Use the output you got from `curl` and add it to your secret like bellow: + +```shell +kubectl create secret generic azure-token \ +--from-literal=token='A-valid-JWT-token' +``` + +#### SAS based auth + +When using SAS auth, we only use the `address` field in the secret. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: azure + namespace: default +spec: + type: azureeventhub + secretRef: + name: azure-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: azure-webhook + namespace: default +stringData: + address: +``` + +Assuming that you have created the Azure event hub and namespace you should be +able to use a similar command to get your connection string. This will give +you the default Root SAS, which is NOT supposed to be used in production. + +```shell +az eventhubs namespace authorization-rule keys list --resource-group --namespace-name --name RootManageSharedAccessKey -o tsv --query primaryConnectionString +# The output should look something like this: +Endpoint=sb://fluxv2.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=yoursaskeygeneatedbyazure +``` + +To create the needed secret: + +```shell +kubectl create secret generic azure-webhook \ +--from-literal=address="Endpoint=sb://fluxv2.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=yoursaskeygeneatedbyazure" +``` + +### Git Commit Status Updates + +The notification-controller can mark Git commits as reconciled by posting +Flux `Kustomization` events to the origin repository using Git SaaS providers APIs. + +#### Example + +The following is an example of how to update the Git commit status for the GitHub repository where +Flux was bootstrapped with `flux bootstrap github --owner=my-gh-org --repository=my-gh-repo`. + +```yaml +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Provider +metadata: + name: github-status + namespace: flux-system +spec: + type: github + address: https://github.com/my-gh-org/my-gh-repo + secretRef: + name: github-token +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Alert +metadata: + name: github-status + namespace: flux-system +spec: + providerRef: + name: github-status + eventSources: + - kind: Kustomization + name: flux-system +``` + +#### GitHub + +When `.spec.type` is set to `github`, the referenced secret must contain a key called `token` with the value set to a +[GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). + +The token must have permissions to update the commit status for the GitHub repository specified in `.spec.address`. + +You can create the secret with `kubectl` like this: + +```shell +kubectl create secret generic github-token --from-literal=token= +``` + +#### GitLab + +When `.spec.type` is set to `gitlab`, the referenced secret must contain a key called `token` with the value set to a +[GitLab personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html). + +The token must have permissions to update the commit status for the GitLab repository specified in `.spec.address`. + +You can create the secret with `kubectl` like this: + +```shell +kubectl create secret generic gitlab-token --from-literal=token= +``` + +#### Gitea + +When `.spec.type` is set to `gitea`, the referenced secret must contain a key called `token` with the value set to a +[Gitea token](https://docs.gitea.io/en-us/api-usage/#generating-and-listing-api-tokens). + +The token must have at least the `write:repository` permission for the provider to +update the commit status for the Gitea repository specified in `.spec.address`. + +{{% alert color="info" title="Gitea 1.20.0 & 1.20.1" %}} +Due to a bug in Gitea 1.20.0 and 1.20.1, these versions require the additional +`read:misc` scope to be applied to the token. +{{% /alert %}} + +You can create the secret with `kubectl` like this: + +```shell +kubectl create secret generic gitea-token --from-literal=token= +``` + +#### BitBucket + +When `.spec.type` is set to `bitbucket`, the referenced secret must contain a key called `token` with the value +set to a BitBucket username and an +[app password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/#Create-an-app-password) +in the format `:`. + +The app password must have `Repositories (Read/Write)` permission for +the BitBucket repository specified in `.spec.address`. + +You can create the secret with `kubectl` like this: + +```shell +kubectl create secret generic bitbucket-token --from-literal=token=: +``` + +#### BitBucket Server/Data Center + +When `.spec.type` is set to `bitbucketserver`, the following auth methods are available: + +- Basic Authentication (username/password) +- [HTTP access tokens](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) + +For Basic Authentication, the referenced secret must contain a `password` field. The `username` field can either come from the [`.spec.username` field of the Provider](https://fluxcd.io/flux/components/notification/providers/#username) or can be defined in the referenced secret. + +You can create the secret with `kubectl` like this: + +```shell +kubectl create secret generic bb-server-username-password --from-literal=username= --from-literal=password= +``` + +For HTTP access tokens, the secret can be created with `kubectl` like this: + +```shell +kubectl create secret generic bb-server-token --from-literal=token= +``` + +The HTTP access token must have `Repositories (Read/Write)` permission for +the repository specified in `.spec.address`. + +#### Azure DevOps + +When `.spec.type` is set to `azuredevops`, the referenced secret must contain a key called `token` with the value set to a +[Azure DevOps personal access token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page). + +The token must have permissions to update the commit status for the Azure DevOps repository specified in `.spec.address`. + +You can create the secret with `kubectl` like this: + +```shell +kubectl create secret generic azuredevops-token --from-literal=token= +``` From 9a5debde3284bc815ed492bbe52b07830de3a056 Mon Sep 17 00:00:00 2001 From: Sunny Date: Thu, 26 Oct 2023 19:51:12 +0000 Subject: [PATCH 8/9] Deprecate v1beta1 & v1beta2 Alert & Provider APIs Signed-off-by: Sunny --- api/v1beta1/alert_types.go | 1 + api/v1beta1/provider_types.go | 1 + api/v1beta2/alert_types.go | 1 + api/v1beta2/provider_types.go | 1 + config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml | 4 ++++ .../crd/bases/notification.toolkit.fluxcd.io_providers.yaml | 4 ++++ 6 files changed, 12 insertions(+) diff --git a/api/v1beta1/alert_types.go b/api/v1beta1/alert_types.go index 4cd9aac39..97bca9a99 100644 --- a/api/v1beta1/alert_types.go +++ b/api/v1beta1/alert_types.go @@ -70,6 +70,7 @@ type AlertStatus struct { // +genclient:Namespaced // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:deprecatedversion:warning="v1beta1 Alert is deprecated, upgrade to v1beta3" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="" // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="" diff --git a/api/v1beta1/provider_types.go b/api/v1beta1/provider_types.go index e3622fed8..7cece20fb 100644 --- a/api/v1beta1/provider_types.go +++ b/api/v1beta1/provider_types.go @@ -113,6 +113,7 @@ type ProviderStatus struct { // +genclient:Namespaced // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:deprecatedversion:warning="v1beta1 Provider is deprecated, upgrade to v1beta3" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="" // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="" diff --git a/api/v1beta2/alert_types.go b/api/v1beta2/alert_types.go index 2d4628900..f270837c3 100644 --- a/api/v1beta2/alert_types.go +++ b/api/v1beta2/alert_types.go @@ -91,6 +91,7 @@ type AlertStatus struct { // +genclient:Namespaced // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:deprecatedversion:warning="v1beta2 Alert is deprecated, upgrade to v1beta3" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="" // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="" diff --git a/api/v1beta2/provider_types.go b/api/v1beta2/provider_types.go index 06541b4fb..10604b831 100644 --- a/api/v1beta2/provider_types.go +++ b/api/v1beta2/provider_types.go @@ -134,6 +134,7 @@ type ProviderStatus struct { // +genclient:Namespaced // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:deprecatedversion:warning="v1beta2 Provider is deprecated, upgrade to v1beta3" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="" // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="" diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml index 30683ac2d..9f52092f7 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml @@ -24,6 +24,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status type: string + deprecated: true + deprecationWarning: v1beta1 Alert is deprecated, upgrade to v1beta3 name: v1beta1 schema: openAPIV3Schema: @@ -218,6 +220,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status type: string + deprecated: true + deprecationWarning: v1beta2 Alert is deprecated, upgrade to v1beta3 name: v1beta2 schema: openAPIV3Schema: diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml index 9fc667e56..cc229fb64 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -24,6 +24,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status type: string + deprecated: true + deprecationWarning: v1beta1 Provider is deprecated, upgrade to v1beta3 name: v1beta1 schema: openAPIV3Schema: @@ -207,6 +209,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status type: string + deprecated: true + deprecationWarning: v1beta2 Provider is deprecated, upgrade to v1beta3 name: v1beta2 schema: openAPIV3Schema: From 6df2c74b2a2ba0296fb3b09125addba584ec5aaa Mon Sep 17 00:00:00 2001 From: Sunny Date: Tue, 21 Nov 2023 17:17:39 +0000 Subject: [PATCH 9/9] event_handler: Use proper ctx for post fail log Use the context containing proper information about the event for logging. Previously, the logged error didn't contain any information about the event, alert or the involved object. Signed-off-by: Sunny --- internal/server/event_handlers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/server/event_handlers.go b/internal/server/event_handlers.go index cd22053db..f7b80dfc8 100644 --- a/internal/server/event_handlers.go +++ b/internal/server/event_handlers.go @@ -202,9 +202,9 @@ func (s *EventServer) dispatchNotification(ctx context.Context, event *eventv1.E } go func(n notifier.Interface, e eventv1.Event) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) + pctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - if err := n.Post(ctx, e); err != nil { + if err := n.Post(pctx, e); err != nil { maskedErrStr, maskErr := masktoken.MaskTokenFromString(err.Error(), token) if maskErr != nil { err = maskErr