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/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/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 f39855acf..f270837c3 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,9 +89,9 @@ type AlertStatus struct {
// +genclient
// +genclient:Namespaced
-// +kubebuilder:storageversion
// +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 069b2d4b2..10604b831 100644
--- a/api/v1beta2/provider_types.go
+++ b/api/v1beta2/provider_types.go
@@ -132,9 +132,9 @@ type ProviderStatus struct {
// +genclient
// +genclient:Namespaced
-// +kubebuilder:storageversion
// +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/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..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:
@@ -427,6 +431,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..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:
@@ -402,6 +406,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/rbac/role.yaml b/config/rbac/role.yaml
index 0511c55ac..cd7f85a95 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -47,14 +47,6 @@ rules:
- patch
- update
- watch
-- apiGroups:
- - notification.toolkit.fluxcd.io
- resources:
- - alerts/status
- verbs:
- - get
- - patch
- - update
- apiGroups:
- notification.toolkit.fluxcd.io
resources:
@@ -67,14 +59,6 @@ rules:
- patch
- update
- watch
-- apiGroups:
- - notification.toolkit.fluxcd.io
- resources:
- - providers/status
- verbs:
- - get
- - patch
- - update
- apiGroups:
- notification.toolkit.fluxcd.io
resources:
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/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
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:
+
+
+Package v1beta3 contains API Schema definitions for the notification v1beta3 API group.
+Resource Types:
+
+
+Alert is the Schema for the alerts API
+
+
+Provider is the Schema for the providers API
+
+
+
+(Appears on:
+Alert)
+
+AlertSpec defines an alerting rule for events involving a list of objects.
+
+
+
+(Appears on:
+Provider)
+
+ProviderSpec defines the desired state of the Provider.
+
+
+
This page was automatically generated with gen-crd-api-reference-docs
+
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=
+```
diff --git a/internal/controller/alert_controller.go b/internal/controller/alert_controller.go
index e0afbe21b..360dd8923 100644
--- a/internal/controller/alert_controller.go
+++ b/internal/controller/alert_controller.go
@@ -1,5 +1,5 @@
/*
-Copyright 2022 The Flux authors
+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.
@@ -18,247 +18,77 @@ 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"
+ 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/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"
+ apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3"
+ "github.com/fluxcd/pkg/runtime/patch"
)
-var (
- ProviderIndexKey = ".metadata.provider"
-)
+// +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 a Alert object
+// AlertReconciler reconciles an Alert object to migrate it to static Alert.
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,
- }).
+ For(&apiv1beta3.Alert{}, builder.WithPredicates(finalizerPredicate{})).
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{}
+ obj := &apiv1beta3.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.
+ // Early return if no migration is needed.
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])})
+ // Examine if the object is under deletion.
+ var delete bool
+ if !obj.ObjectMeta.DeletionTimestamp.IsZero() {
+ delete = true
}
- 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,
+ // 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
}
- 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
+ patcher, err := patch.NewHelper(obj, r.Client)
+ if err != nil {
+ return ctrl.Result{}, err
}
- // 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
- }
+ defer func() {
+ if err := patcher.Patch(ctx, obj); err != nil {
+ retErr = err
+ }
+ }()
- // 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 notification-controller finalizer.
+ controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer)
- // 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
- }
- }
+ 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 nil
+ return
}
diff --git a/internal/controller/alert_controller_test.go b/internal/controller/alert_controller_test.go
index ea97151d4..fdd8717a8 100644
--- a/internal/controller/alert_controller_test.go
+++ b/internal/controller/alert_controller_test.go
@@ -1,5 +1,5 @@
/*
-Copyright 2022 The Flux authors
+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.
@@ -17,532 +17,105 @@ 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/fluxcd/pkg/apis/meta"
+ "github.com/fluxcd/pkg/runtime/patch"
. "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"
+ apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3"
)
-func TestAlertReconciler_deleteBeforeFinalizer(t *testing.T) {
+func TestAlertReconciler(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())
-}
+ timeout := 10 * time.Second
-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")
+ testns, err := testEnv.CreateNamespace(ctx, "alert-test")
+ g.Expect(err).ToNot(HaveOccurred())
- provider := &apiv1beta2.Provider{
- ObjectMeta: metav1.ObjectMeta{
- Name: providerName,
- Namespace: namespaceName,
- },
- Spec: apiv1beta2.ProviderSpec{
- Type: "generic",
- Address: "https://webhook.internal",
- },
- }
+ t.Cleanup(func() {
+ g.Expect(testEnv.Cleanup(ctx, testns)).ToNot(HaveOccurred())
+ })
- alert := &apiv1beta2.Alert{
+ alert := &apiv1beta3.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: "*",
- },
- },
+ Namespace: testns.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)
+ alertKey := client.ObjectKeyFromObject(alert)
- g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed())
+ // Remove finalizer at create.
- 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")
+ 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())
- 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())
+ _ = testEnv.Get(ctx, alertKey, alert)
+ return !controllerutil.ContainsFinalizer(alert, apiv1.NotificationFinalizer)
+ }, timeout, time.Second).Should(BeTrue())
- _, err = manager.Apply(context.Background(), repo, ssa.ApplyOptions{
- Force: true,
- })
- g.Expect(err).ToNot(HaveOccurred())
+ // Remove finalizer at update.
- _, err = manager.Apply(context.Background(), secondRepo, ssa.ApplyOptions{
- Force: true,
- })
+ 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())
- 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())
+ _ = testEnv.Get(ctx, alertKey, alert)
+ return !controllerutil.ContainsFinalizer(alert, apiv1.NotificationFinalizer)
+ }, timeout, 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()
+ // Remove finalizer at delete.
- 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())
+ patchHelper, err = patch.NewHelper(alert, testEnv.Client)
+ g.Expect(err).ToNot(HaveOccurred())
- // wait for ready
+ // 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 {
- 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()
+ _ = k8sClient.Get(ctx, alertKey, alert)
+ return alert.Spec.Suspend == true
+ }, timeout).Should(BeTrue())
- 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,
- },
- }
+ patchHelper, err = patch.NewHelper(alert, testEnv.Client)
+ g.Expect(err).ToNot(HaveOccurred())
- 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()
+ // 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())
- event = tt.modifyEventFunc(event)
- testSent(g)
- if tt.forwarded {
- testForwarded(g)
- } else {
- testFiltered(g)
- }
- })
- }
+ // 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
index d1b753b44..fee2e0db8 100644
--- a/internal/controller/provider_controller.go
+++ b/internal/controller/provider_controller.go
@@ -1,5 +1,5 @@
/*
-Copyright 2022 The Flux authors
+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.
@@ -18,317 +18,79 @@ 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"
+ apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3"
+ "github.com/fluxcd/pkg/runtime/patch"
)
-// ProviderReconciler reconciles a Provider object
+// +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
- 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,
- }).
+ For(&apiv1beta3.Provider{}, builder.WithPredicates(finalizerPredicate{})).
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{}
+ obj := &apiv1beta3.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.
+ // Early return if no migration is needed.
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)
- }
+ // Examine if the object is under deletion.
+ var delete bool
+ if !obj.ObjectMeta.DeletionTimestamp.IsZero() {
+ delete = true
}
- 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)
- }
- }
+ // 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
}
- if address == "" {
- return fmt.Errorf("no address found in 'spec.address' nor in `spec.secretRef`")
+ patcher, err := patch.NewHelper(obj, r.Client)
+ if err != nil {
+ return ctrl.Result{}, 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}
-
- 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)
+ defer func() {
+ if err := patcher.Patch(ctx, obj); err != nil {
+ retErr = err
}
- }
-
- 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)
- }
+ // Remove the notification-controller finalizer.
+ controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer)
- // 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
- }
- }
+ 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 nil
+ return
}
diff --git a/internal/controller/provider_controller_test.go b/internal/controller/provider_controller_test.go
index 652156acb..3a25e9246 100644
--- a/internal/controller/provider_controller_test.go
+++ b/internal/controller/provider_controller_test.go
@@ -1,5 +1,5 @@
/*
-Copyright 2022 The Flux authors
+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.
@@ -17,320 +17,103 @@ limitations under the License.
package controller
import (
- "context"
"fmt"
- "os"
"testing"
"time"
+ "github.com/fluxcd/pkg/runtime/patch"
. "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"
+ apiv1beta3 "github.com/fluxcd/notification-controller/api/v1beta3"
)
-func TestProviderReconciler_deleteBeforeFinalizer(t *testing.T) {
+func TestProviderReconciler(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())
+ timeout := 10 * time.Second
- resultP.Spec.SecretRef = nil
- resultP.Spec.Proxy = "https://proxy.internal|"
- g.Expect(k8sClient.Update(context.Background(), resultP)).To(Succeed())
+ testns, err := testEnv.CreateNamespace(ctx, "provider-test")
+ g.Expect(err).ToNot(HaveOccurred())
- 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())
+ t.Cleanup(func() {
+ g.Expect(testEnv.Cleanup(ctx, testns)).ToNot(HaveOccurred())
})
-}
-
-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{
+ provider := &apiv1beta3.Provider{
ObjectMeta: metav1.ObjectMeta{
- Name: providerKey.Name,
- Namespace: providerKey.Namespace,
- },
- Spec: apiv1beta2.ProviderSpec{
- Type: "generic",
- Address: "https://webhook.internal",
- CertSecretRef: &meta.LocalObjectReference{Name: secretName},
+ Name: fmt.Sprintf("provider-%s", randStringRunes(5)),
+ Namespace: testns.Name,
},
}
- g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed())
+ providerKey := client.ObjectKeyFromObject(provider)
- 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())
+ // Remove finalizer at create.
- r := &ProviderReconciler{
- Client: k8sClient,
- EventRecorder: record.NewFakeRecorder(32),
+ provider.ObjectMeta.Finalizers = append(provider.ObjectMeta.Finalizers, "foo.bar", apiv1.NotificationFinalizer)
+ provider.Spec = apiv1beta3.ProviderSpec{
+ Type: "slack",
}
-
- 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(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)
}
- 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"))
- })
+ return false
+ }, timeout).Should(BeTrue())
}
diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go
index 5aa1ddbe2..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"),
@@ -72,23 +74,17 @@ func TestMain(m *testing.M) {
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))
+ }).SetupWithManager(testEnv); err != nil {
+ panic(fmt.Sprintf("Failed to start AlertReconciler: %v", err))
}
if err := (&ProviderReconciler{
Client: testEnv,
- Metrics: testMetricsH,
ControllerName: controllerName,
EventRecorder: testEnv.GetEventRecorderFor(controllerName),
- }).SetupWithManagerAndOptions(testEnv, ProviderReconcilerOptions{
- RateLimiter: controller.GetDefaultRateLimiter(),
- }); err != nil {
+ }).SetupWithManager(testEnv); err != nil {
panic(fmt.Sprintf("Failed to start ProviderReconciler: %v", err))
}
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/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/internal/server/event_handlers.go b/internal/server/event_handlers.go
index 1cd1adee1..f7b80dfc8 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,10 +38,18 @@ 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"
)
+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)
@@ -50,261 +58,365 @@ 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")
+ s.Eventf(alert, corev1.EventTypeWarning, "NotificationDispatchFailed",
+ "failed to dispatch notification for %s: %s", involvedObjectString(event.InvolvedObject), err)
}
+ }
- // 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) {
+ continue
+ }
+ // Check if the event message is allowed for the alert based on the
+ // exclusion list.
+ if s.messageIsExcluded(ctx, event.Message, alert) {
+ 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.eventMatchesAlertSource(ctx, event, alert, source) {
+ 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 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
+ }
- // 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 alert.Spec.InclusionList {
+ if r, err := regexp.Compile(exp); err == nil {
+ if r.Match([]byte(msg)) {
+ return true
}
+ } else {
+ 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
+}
- var provider apiv1beta2.Provider
- providerName := types.NamespacedName{Namespace: alert.Namespace, Name: alert.Spec.ProviderRef.Name}
+// 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
+ }
- err = s.kubeClient.Get(ctx, providerName, &provider)
- if err != nil {
- alertLogger.Error(err, "failed to read provider")
- continue
+ for _, exp := range alert.Spec.ExclusionList {
+ if r, err := regexp.Compile(exp); err == nil {
+ if r.Match([]byte(msg)) {
+ return true
}
+ } else {
+ 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
+}
- 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) {
+ pctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ if err := n.Post(pctx, e); err != nil {
+ maskedErrStr, maskErr := masktoken.MaskTokenFromString(err.Error(), token)
+ if maskErr != nil {
+ err = maskErr
+ } else {
+ err = errors.New(maskedErrStr)
}
+ 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)
- 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
- }
- }
+ return nil
+}
- if webhook == "" {
- alertLogger.Error(nil, "provider has no address")
- continue
+// 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', 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)
+ }
+
+ 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)
}
+ }
- factory := notifier.NewFactory(webhook, proxy, username, provider.Spec.Channel, token, headers, certPool, password, string(provider.UID))
- sender, err := factory.Notifier(provider.Spec.Type)
+ if p, ok := secret.Data["password"]; ok {
+ password = string(p)
+ }
+
+ if p, ok := secret.Data["proxy"]; ok {
+ proxy = string(p)
+ _, err := url.Parse(proxy)
if err != nil {
- alertLogger.Error(err, "failed to initialize provider")
- continue
+ return nil, "", fmt.Errorf("invalid proxy in secret '%s': %w", proxy, err)
}
+ }
- 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)
+ if t, ok := secret.Data["token"]; ok {
+ token = string(t)
}
- w.WriteHeader(http.StatusAccepted)
- }
-}
+ if u, ok := secret.Data["username"]; ok {
+ username = string(u)
+ }
-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()))
+ if h, ok := secret.Data["headers"]; ok {
+ err := yaml.Unmarshal(h, &headers)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read headers from secret: %w", err)
}
+ }
+ }
- if source.Name == "*" && labelMatch || event.InvolvedObject.Name == source.Name {
- return true
+ var certPool *x509.CertPool
+ if provider.Spec.CertSecretRef != nil {
+ var secret corev1.Secret
+ secretName := types.NamespacedName{Namespace: provider.Namespace, Name: provider.Spec.CertSecretRef.Name}
+
+ err := kubeClient.Get(ctx, secretName, &secret)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read cert secret: %w", err)
+ }
+
+ 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 "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)
}
+ 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")
}
}
- return false
+ 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
+}
+
+// eventMatchesAlertSource returns if a given event matches with the given alert
+// source configuration and severity.
+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.
+ if event.InvolvedObject.Namespace != source.Namespace ||
+ event.InvolvedObject.Kind != source.Kind {
+ return false
+ }
+
+ // 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
+ }
+
+ // 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")
+ 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", 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()))
}
-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)
@@ -316,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 1f7a9c394..e8a2a9eb9 100644
--- a/internal/server/event_handlers_test.go
+++ b/internal/server/event_handlers_test.go
@@ -18,32 +18,972 @@ 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"
+ "k8s.io/client-go/tools/record"
+ 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,
+ EventRecorder: record.NewFakeRecorder(32),
+ }
+
+ 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,
+ EventRecorder: record.NewFakeRecorder(32),
+ }
+
+ 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,
+ EventRecorder: record.NewFakeRecorder(32),
+ }
+
+ _, 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,
+ EventRecorder: record.NewFakeRecorder(32),
+ }
+ alert := &apiv1beta3.Alert{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-alert",
+ Namespace: "test-ns",
+ },
+ Spec: apiv1beta3.AlertSpec{
+ EventSeverity: 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: logr.New(nil)}
+ s := &EventServer{
+ logger: log.Log,
+ EventRecorder: record.NewFakeRecorder(32),
+ }
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 +997,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 +1008,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 +1025,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",
},
@@ -100,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 aa9a0b38c..5ec4704e8 100644
--- a/internal/server/event_server.go
+++ b/internal/server/event_server.go
@@ -33,12 +33,17 @@ 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"
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
@@ -47,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 fc829e368..9aec6ace0 100644
--- a/internal/server/event_server_test.go
+++ b/internal/server/event_server_test.go
@@ -21,29 +21,325 @@ 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"
+ "k8s.io/client-go/tools/record"
+ 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, kclient, record.NewFakeRecorder(32), 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 +354,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 +365,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 +376,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 +387,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 +398,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 +409,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 +420,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 +434,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 +448,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 +462,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 +476,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 +490,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 +513,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 +522,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
diff --git a/main.go b/main.go
index 7af53bc83..5530ea7ba 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
}
@@ -174,25 +176,21 @@ func main() {
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 {
+ }).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,
- Metrics: metricsH,
EventRecorder: mgr.GetEventRecorderFor(controllerName),
- }).SetupWithManagerAndOptions(mgr, controller.AlertReconcilerOptions{
- RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
- }); err != nil {
+ }).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,
@@ -222,7 +220,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)