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:

+ +

notification.toolkit.fluxcd.io/v1beta3

+

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

+Resource Types: + +

Alert +

+

Alert is the Schema for the alerts API

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

ProviderRef specifies which Provider this Alert should use.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
+
+
+
+

Provider +

+

Provider is the Schema for the providers API

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

Type specifies which Provider implementation to use.

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

Channel specifies the destination channel where events should be posted.

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

Username specifies the name under which events are posted.

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

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

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

Timeout for sending alerts to the Provider.

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

Proxy the HTTP/S address of the proxy server.

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

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

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

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

+

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

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

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

+
+
+
+
+

AlertSpec +

+

+(Appears on: +Alert) +

+

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

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

ProviderRef specifies which Provider this Alert should use.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+
+
+
+

ProviderSpec +

+

+(Appears on: +Provider) +

+

ProviderSpec defines the desired state of the Provider.

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

Type specifies which Provider implementation to use.

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

Channel specifies the destination channel where events should be posted.

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

Username specifies the name under which events are posted.

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

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

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

Timeout for sending alerts to the Provider.

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

Proxy the HTTP/S address of the proxy server.

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

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

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

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

+

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

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

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

+
+
+
+
+

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

+
diff --git a/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)