diff --git a/Makefile b/Makefile index 73a2877dc..4f3274ac5 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ IMG ?= fluxcd/notification-controller:latest # Produce CRDs that work back to Kubernetes 1.16 CRD_OPTIONS ?= crd:crdVersions=v1 -SOURCE_VER ?= v0.24.0 +SOURCE_VER ?= v0.31.0 # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -76,7 +76,7 @@ manifests: controller-gen # Generate API reference documentation api-docs: gen-crd-api-reference-docs - $(GEN_CRD_API_REFERENCE_DOCS) -api-dir=./api/v1beta1 -config=./hack/api-docs/config.json -template-dir=./hack/api-docs/template -out-file=./docs/api/notification.md + $(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/notification.md # Run go mod tidy tidy: diff --git a/PROJECT b/PROJECT index f3217e44c..c53381c21 100644 --- a/PROJECT +++ b/PROJECT @@ -10,4 +10,13 @@ resources: - group: notification kind: Receiver version: v1beta1 +- group: notification + kind: Provider + version: v1beta2 +- group: notification + kind: Alert + version: v1beta2 +- group: notification + kind: Receiver + version: v1beta2 version: "2" diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index c20ff16bc..a7f1bece8 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -2,7 +2,7 @@ // +build !ignore_autogenerated /* -Copyright 2020 The Flux authors +Copyright 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v1beta2/alert_types.go b/api/v1beta2/alert_types.go new file mode 100644 index 000000000..88a008389 --- /dev/null +++ b/api/v1beta2/alert_types.go @@ -0,0 +1,115 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/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 []CrossNamespaceObjectReference `json:"eventSources"` + + // 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"` +} + +// AlertStatus defines the observed state of the Alert. +type AlertStatus struct { + meta.ReconcileRequestStatus `json:",inline"` + + // Conditions holds the conditions for the Alert. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the last observed generation. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// +genclient +// +genclient:Namespaced +// +kubebuilder:storageversion +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +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="" + +// 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:default:={"observedGeneration":-1} + Status AlertStatus `json:"status,omitempty"` +} + +// GetConditions returns the status conditions of the object. +func (in *Alert) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the status conditions on the object. +func (in *Alert) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// AlertList contains a list of Alerts. +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/v1beta2/condition_types.go b/api/v1beta2/condition_types.go new file mode 100644 index 000000000..9ad629ec0 --- /dev/null +++ b/api/v1beta2/condition_types.go @@ -0,0 +1,31 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +const NotificationFinalizer = "finalizers.fluxcd.io" + +const ( + // InitializedReason represents the fact that a given resource has been initialized. + InitializedReason string = "Initialized" + + // ValidationFailedReason represents the fact that some part of the spec of a given resource + // couldn't be validated. + ValidationFailedReason string = "ValidationFailed" + + // TokenNotFoundReason represents the fact that receiver token can't be found. + TokenNotFoundReason string = "TokenNotFound" +) diff --git a/api/v1beta2/doc.go b/api/v1beta2/doc.go new file mode 100644 index 000000000..c1a08a328 --- /dev/null +++ b/api/v1beta2/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1beta2 contains API Schema definitions for the notification v1beta2 API group. +// +kubebuilder:object:generate=true +// +groupName=notification.toolkit.fluxcd.io +package v1beta2 diff --git a/api/v1beta2/groupversion_info.go b/api/v1beta2/groupversion_info.go new file mode 100644 index 000000000..35598eb69 --- /dev/null +++ b/api/v1beta2/groupversion_info.go @@ -0,0 +1,33 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +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: "v1beta2"} + + // 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/v1beta2/provider_types.go b/api/v1beta2/provider_types.go new file mode 100644 index 000000000..bd0e5cc2c --- /dev/null +++ b/api/v1beta2/provider_types.go @@ -0,0 +1,180 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +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" + BitbucketProvider string = "bitbucket" + AzureDevOpsProvider string = "azuredevops" + GoogleChatProvider string = "googlechat" + WebexProvider string = "webex" + SentryProvider string = "sentry" + AzureEventHubProvider string = "azureeventhub" + TelegramProvider string = "telegram" + LarkProvider string = "lark" + Matrix string = "matrix" + OpsgenieProvider string = "opsgenie" + AlertManagerProvider string = "alertmanager" +) + +// 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;bitbucket;azuredevops;googlechat;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch; + // +required + Type string `json:"type"` + + // Interval at which to reconcile the Provider with its Secret references. + // +kubebuilder:default="600s" + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m|h))+$" + // +optional + Interval *metav1.Duration `json:"interval,omitempty"` + + // 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 HTTP/S incoming webhook address of this Provider. + // +kubebuilder:validation:Pattern="^(http|https)://.*$" + // +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 (`caFile`). + // +optional + CertSecretRef *meta.LocalObjectReference `json:"certSecretRef,omitempty"` + + // Suspend tells the controller to suspend subsequent + // events handling for this Provider. + // +optional + Suspend bool `json:"suspend,omitempty"` +} + +// ProviderStatus defines the observed state of the Provider. +type ProviderStatus struct { + meta.ReconcileRequestStatus `json:",inline"` + + // Conditions holds the conditions for the Provider. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the last reconciled generation. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// +genclient +// +genclient:Namespaced +// +kubebuilder:storageversion +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +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="" + +// 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:default:={"observedGeneration":-1} + Status ProviderStatus `json:"status,omitempty"` +} + +// GetConditions returns the status conditions of the object. +func (in *Provider) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the status conditions on the object. +func (in *Provider) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// ProviderList contains a list of Providers. +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 +} + +// GetRequeueAfter returns the duration after which the Provider must be +// reconciled again. +func (in *Provider) GetRequeueAfter() time.Duration { + return in.Spec.Interval.Duration +} diff --git a/api/v1beta2/receiver_types.go b/api/v1beta2/receiver_types.go new file mode 100644 index 000000000..b0cdd2a51 --- /dev/null +++ b/api/v1beta2/receiver_types.go @@ -0,0 +1,149 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "crypto/sha256" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/fluxcd/pkg/apis/meta" +) + +const ( + ReceiverKind string = "Receiver" + ReceiverWebhookPath string = "/hook/" + GenericReceiver string = "generic" + GenericHMACReceiver string = "generic-hmac" + GitHubReceiver string = "github" + GitLabReceiver string = "gitlab" + BitbucketReceiver string = "bitbucket" + HarborReceiver string = "harbor" + DockerHubReceiver string = "dockerhub" + QuayReceiver string = "quay" + GCRReceiver string = "gcr" + NexusReceiver string = "nexus" + ACRReceiver string = "acr" +) + +// ReceiverSpec defines the desired state of the Receiver. +type ReceiverSpec struct { + // Type of webhook sender, used to determine + // the validation procedure and payload deserialization. + // +kubebuilder:validation:Enum=generic;generic-hmac;github;gitlab;bitbucket;harbor;dockerhub;quay;gcr;nexus;acr + // +required + Type string `json:"type"` + + // Interval at which to reconcile the Receiver with its Secret references. + // +kubebuilder:default="600s" + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m|h))+$" + // +optional + Interval *metav1.Duration `json:"interval,omitempty"` + + // Events specifies the list of event types to handle, + // e.g. 'push' for GitHub or 'Push Hook' for GitLab. + // +optional + Events []string `json:"events"` + + // A list of resources to be notified about changes. + // +required + Resources []CrossNamespaceObjectReference `json:"resources"` + + // SecretRef specifies the Secret containing the token used + // to validate the payload authenticity. + // +required + SecretRef meta.LocalObjectReference `json:"secretRef,omitempty"` + + // Suspend tells the controller to suspend subsequent + // events handling for this receiver. + // +optional + Suspend bool `json:"suspend,omitempty"` +} + +// ReceiverStatus defines the observed state of the Receiver. +type ReceiverStatus struct { + meta.ReconcileRequestStatus `json:",inline"` + + // Conditions holds the conditions for the Receiver. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // URL is the generated incoming webhook address in the format + // of '/hook/sha256sum(token+name+namespace)'. + // Deprecated: Replaced by WebhookPath. + // +optional + URL string `json:"url,omitempty"` + + // WebhookPath is the generated incoming webhook address in the format + // of '/hook/sha256sum(token+name+namespace)'. + // +optional + WebhookPath string `json:"webhookPath,omitempty"` + + // ObservedGeneration is the last observed generation of the Receiver object. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// GetConditions returns the status conditions of the object. +func (in *Receiver) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the status conditions on the object. +func (in *Receiver) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// GetWebhookPath returns the incoming webhook path for the given token. +func (in *Receiver) GetWebhookPath(token string) string { + digest := sha256.Sum256([]byte(token + in.GetName() + in.GetNamespace())) + return fmt.Sprintf("%s%x", ReceiverWebhookPath, digest) +} + +// +genclient +// +genclient:Namespaced +// +kubebuilder:storageversion +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +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="" + +// Receiver is the Schema for the receivers API. +type Receiver struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ReceiverSpec `json:"spec,omitempty"` + // +kubebuilder:default:={"observedGeneration":-1} + Status ReceiverStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ReceiverList contains a list of Receivers. +type ReceiverList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Receiver `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Receiver{}, &ReceiverList{}) +} diff --git a/api/v1beta2/reference_types.go b/api/v1beta2/reference_types.go new file mode 100644 index 000000000..50594f3fd --- /dev/null +++ b/api/v1beta2/reference_types.go @@ -0,0 +1,49 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +// CrossNamespaceObjectReference contains enough information to let you locate the +// typed referenced object at cluster level +type CrossNamespaceObjectReference struct { + // API version of the referent. + // +optional + APIVersion string `json:"apiVersion,omitempty"` + + // Kind of the referent. + // +kubebuilder:validation:Enum=Bucket;GitRepository;Kustomization;HelmRelease;HelmChart;HelmRepository;ImageRepository;ImagePolicy;ImageUpdateAutomation;OCIRepository + // +required + Kind string `json:"kind,omitempty"` + + // Name of the referent. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=53 + // +required + Name string `json:"name"` + + // Namespace of the referent. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=53 + // +kubebuilder:validation:Optional + // +optional + Namespace string `json:"namespace,omitempty"` + + // 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. + // +optional + MatchLabels map[string]string `json:"matchLabels,omitempty"` +} diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go new file mode 100644 index 000000000..431aac8ba --- /dev/null +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -0,0 +1,392 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta2 + +import ( + "github.com/fluxcd/pkg/apis/meta" + "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) + in.Status.DeepCopyInto(&out.Status) +} + +// 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([]CrossNamespaceObjectReference, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + 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 *AlertStatus) DeepCopyInto(out *AlertStatus) { + *out = *in + out.ReconcileRequestStatus = in.ReconcileRequestStatus + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertStatus. +func (in *AlertStatus) DeepCopy() *AlertStatus { + if in == nil { + return nil + } + out := new(AlertStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrossNamespaceObjectReference) DeepCopyInto(out *CrossNamespaceObjectReference) { + *out = *in + if in.MatchLabels != nil { + in, out := &in.MatchLabels, &out.MatchLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossNamespaceObjectReference. +func (in *CrossNamespaceObjectReference) DeepCopy() *CrossNamespaceObjectReference { + if in == nil { + return nil + } + out := new(CrossNamespaceObjectReference) + 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) + in.Status.DeepCopyInto(&out.Status) +} + +// 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.Interval != nil { + in, out := &in.Interval, &out.Interval + *out = new(v1.Duration) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(v1.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 +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderStatus) DeepCopyInto(out *ProviderStatus) { + *out = *in + out.ReconcileRequestStatus = in.ReconcileRequestStatus + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderStatus. +func (in *ProviderStatus) DeepCopy() *ProviderStatus { + if in == nil { + return nil + } + out := new(ProviderStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Receiver) DeepCopyInto(out *Receiver) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Receiver. +func (in *Receiver) DeepCopy() *Receiver { + if in == nil { + return nil + } + out := new(Receiver) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Receiver) 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 *ReceiverList) DeepCopyInto(out *ReceiverList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Receiver, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReceiverList. +func (in *ReceiverList) DeepCopy() *ReceiverList { + if in == nil { + return nil + } + out := new(ReceiverList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ReceiverList) 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 *ReceiverSpec) DeepCopyInto(out *ReceiverSpec) { + *out = *in + if in.Interval != nil { + in, out := &in.Interval, &out.Interval + *out = new(v1.Duration) + **out = **in + } + if in.Events != nil { + in, out := &in.Events, &out.Events + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]CrossNamespaceObjectReference, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReceiverSpec. +func (in *ReceiverSpec) DeepCopy() *ReceiverSpec { + if in == nil { + return nil + } + out := new(ReceiverSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReceiverStatus) DeepCopyInto(out *ReceiverStatus) { + *out = *in + out.ReconcileRequestStatus = in.ReconcileRequestStatus + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReceiverStatus. +func (in *ReceiverStatus) DeepCopy() *ReceiverStatus { + if in == nil { + return nil + } + out := new(ReceiverStatus) + 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 39707e0d4..a9d7465d1 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml @@ -206,6 +206,210 @@ spec: type: object type: object served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1beta2 + 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: + 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. + type: object + name: + description: Name of the referent. + maxLength: 53 + minLength: 1 + type: string + namespace: + description: Namespace of the referent. + maxLength: 53 + minLength: 1 + type: string + required: + - 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 + 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 + status: + default: + observedGeneration: -1 + description: AlertStatus defines the observed state of the Alert. + properties: + conditions: + description: Conditions holds the conditions for the Alert. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value can + be detected. + type: string + observedGeneration: + description: ObservedGeneration is the last observed generation. + format: int64 + type: integer + type: object + type: object + served: true storage: true subresources: status: {} diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml index f80113523..c9c1d220a 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -195,6 +195,207 @@ spec: type: object type: object served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1beta2 + 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 HTTP/S incoming webhook address + of this Provider. + maxLength: 2048 + pattern: ^(http|https)://.*$ + type: string + certSecretRef: + description: CertSecretRef specifies the Secret containing a PEM-encoded + CA certificate (`caFile`). + 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 + interval: + default: 600s + description: Interval at which to reconcile the Provider with its + Secret references. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + 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 + - bitbucket + - azuredevops + - googlechat + - webex + - sentry + - azureeventhub + - telegram + - lark + - matrix + - opsgenie + - alertmanager + - grafana + - githubdispatch + type: string + username: + description: Username specifies the name under which events are posted. + maxLength: 2048 + type: string + required: + - type + type: object + status: + default: + observedGeneration: -1 + description: ProviderStatus defines the observed state of the Provider. + properties: + conditions: + description: Conditions holds the conditions for the Provider. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value can + be detected. + type: string + observedGeneration: + description: ObservedGeneration is the last reconciled generation. + format: int64 + type: integer + type: object + type: object + served: true storage: true subresources: status: {} diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml index d2d1df8bb..3837d819f 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml @@ -214,6 +214,227 @@ spec: type: object type: object served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + name: v1beta2 + schema: + openAPIV3Schema: + description: Receiver is the Schema for the receivers 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: ReceiverSpec defines the desired state of the Receiver. + properties: + events: + description: Events specifies the list of event types to handle, e.g. + 'push' for GitHub or 'Push Hook' for GitLab. + items: + type: string + type: array + interval: + default: 600s + description: Interval at which to reconcile the Receiver with its + Secret references. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ + type: string + resources: + description: A list of resources to be notified about changes. + 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. + type: object + name: + description: Name of the referent. + maxLength: 53 + minLength: 1 + type: string + namespace: + description: Namespace of the referent. + maxLength: 53 + minLength: 1 + type: string + required: + - name + type: object + type: array + secretRef: + description: SecretRef specifies the Secret containing the token used + to validate the payload authenticity. + 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 receiver. + type: boolean + type: + description: Type of webhook sender, used to determine the validation + procedure and payload deserialization. + enum: + - generic + - generic-hmac + - github + - gitlab + - bitbucket + - harbor + - dockerhub + - quay + - gcr + - nexus + - acr + type: string + required: + - resources + - type + type: object + status: + default: + observedGeneration: -1 + description: ReceiverStatus defines the observed state of the Receiver. + properties: + conditions: + description: Conditions holds the conditions for the Receiver. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastHandledReconcileAt: + description: LastHandledReconcileAt holds the value of the most recent + reconcile request value, so a change of the annotation value can + be detected. + type: string + observedGeneration: + description: ObservedGeneration is the last observed generation of + the Receiver object. + format: int64 + type: integer + url: + description: 'URL is the generated incoming webhook address in the + format of ''/hook/sha256sum(token+name+namespace)''. Deprecated: + Replaced by WebhookPath.' + type: string + webhookPath: + description: WebhookPath is the generated incoming webhook address + in the format of '/hook/sha256sum(token+name+namespace)'. + type: string + type: object + type: object + served: true storage: true subresources: status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 85f4270ef..16025e8ee 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -5,6 +5,13 @@ metadata: creationTimestamp: null name: manager-role rules: +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch - apiGroups: - "" resources: @@ -137,3 +144,19 @@ rules: - helmrepositories/status verbs: - get +- apiGroups: + - source.fluxcd.io + resources: + - ocirepositories + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - source.fluxcd.io + resources: + - ocirepositories/status + verbs: + - get diff --git a/controllers/alert_controller.go b/controllers/alert_controller.go index 73be26647..1f2714e85 100644 --- a/controllers/alert_controller.go +++ b/controllers/alert_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +Copyright 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,8 +21,8 @@ import ( "fmt" "time" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" kerrors "k8s.io/apimachinery/pkg/util/errors" ctrl "sigs.k8s.io/controller-runtime" @@ -43,7 +43,7 @@ import ( "github.com/fluxcd/pkg/runtime/predicates" kuberecorder "k8s.io/client-go/tools/record" - "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" ) var ( @@ -56,7 +56,7 @@ type AlertReconciler struct { helper.Metrics kuberecorder.EventRecorder - Scheme *runtime.Scheme + ControllerName string } type AlertReconcilerOptions struct { @@ -69,9 +69,9 @@ func (r *AlertReconciler) SetupWithManager(mgr ctrl.Manager) error { } func (r *AlertReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts AlertReconcilerOptions) error { - if err := mgr.GetFieldIndexer().IndexField(context.TODO(), &v1beta1.Alert{}, ProviderIndexKey, + if err := mgr.GetFieldIndexer().IndexField(context.TODO(), &apiv1.Alert{}, ProviderIndexKey, func(o client.Object) []string { - alert := o.(*v1beta1.Alert) + alert := o.(*apiv1.Alert) return []string{ fmt.Sprintf("%s/%s", alert.GetNamespace(), alert.Spec.ProviderRef.Name), } @@ -80,10 +80,11 @@ func (r *AlertReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts Aler } return ctrl.NewControllerManagedBy(mgr). - For(&v1beta1.Alert{}). - WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{})). + For(&apiv1.Alert{}, builder.WithPredicates( + predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}), + )). Watches( - &source.Kind{Type: &v1beta1.Provider{}}, + &source.Kind{Type: &apiv1.Provider{}}, handler.EnqueueRequestsFromMapFunc(r.requestsForProviderChange), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). @@ -97,97 +98,82 @@ func (r *AlertReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts Aler // +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) { - start := time.Now() + reconcileStart := time.Now() log := ctrl.LoggerFrom(ctx) - alert := &v1beta1.Alert{} - if err := r.Get(ctx, req.NamespacedName, alert); err != nil { + obj := &apiv1.Alert{} + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - // record suspension metrics - r.RecordSuspend(ctx, alert, alert.Spec.Suspend) - - if alert.Spec.Suspend { - log.Info("Reconciliation is suspended for this object") - return ctrl.Result{}, nil - } - - patchHelper, err := patch.NewHelper(alert, r.Client) - if err != nil { - return ctrl.Result{}, err - } + // Initialize the runtime patcher with the current version of the object. + patcher := patch.NewSerialPatcher(obj, r.Client) defer func() { - patchOpts := []patch.Option{ - patch.WithOwnedConditions{ - Conditions: []string{ - meta.ReadyCondition, - meta.ReconcilingCondition, - meta.StalledCondition, - }, - }, + // Patch finalizers, status and conditions. + if err := r.patch(ctx, obj, patcher); err != nil { + retErr = kerrors.NewAggregate([]error{retErr, err}) } - if retErr == nil && (result.IsZero() || !result.Requeue) { - conditions.Delete(alert, meta.ReconcilingCondition) - - patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{}) + // Record Prometheus metrics. + r.Metrics.RecordReadiness(ctx, obj) + r.Metrics.RecordDuration(ctx, obj, reconcileStart) + r.Metrics.RecordSuspend(ctx, obj, obj.Spec.Suspend) - readyCondition := conditions.Get(alert, meta.ReadyCondition) - switch readyCondition.Status { - case metav1.ConditionFalse: - // As we are no longer reconciling and the end-state is not ready, the reconciliation has stalled - conditions.MarkStalled(alert, readyCondition.Reason, readyCondition.Message) - case metav1.ConditionTrue: - // As we are no longer reconciling and the end-state is ready, the reconciliation is no longer stalled - conditions.Delete(alert, meta.StalledCondition) - } + // Emit warning event if the reconciliation failed. + if retErr != nil { + r.Event(obj, corev1.EventTypeWarning, meta.FailedReason, retErr.Error()) } - if err := patchHelper.Patch(ctx, alert, patchOpts...); err != nil { - retErr = kerrors.NewAggregate([]error{retErr, err}) + // 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) } - - r.Metrics.RecordReadiness(ctx, alert) - r.Metrics.RecordDuration(ctx, alert, start) }() - if !controllerutil.ContainsFinalizer(alert, v1beta1.NotificationFinalizer) { - controllerutil.AddFinalizer(alert, v1beta1.NotificationFinalizer) + if !controllerutil.ContainsFinalizer(obj, apiv1.NotificationFinalizer) { + controllerutil.AddFinalizer(obj, apiv1.NotificationFinalizer) result = ctrl.Result{Requeue: true} return } - if !alert.ObjectMeta.DeletionTimestamp.IsZero() { - controllerutil.RemoveFinalizer(alert, v1beta1.NotificationFinalizer) + if !obj.ObjectMeta.DeletionTimestamp.IsZero() { + controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer) result = ctrl.Result{} return } - return r.reconcile(ctx, alert) + // 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 *v1beta1.Alert) (ctrl.Result, error) { - // Mark the resource as under reconciliation - conditions.MarkReconciling(alert, meta.ProgressingReason, "") +func (r *AlertReconciler) reconcile(ctx context.Context, alert *apiv1.Alert) (ctrl.Result, error) { + // Mark the resource as under reconciliation. + conditions.MarkReconciling(alert, meta.ProgressingReason, "Reconciliation in progress") - // validate alert spec and provider - if err := r.validate(ctx, alert); err != nil { - conditions.MarkFalse(alert, meta.ReadyCondition, v1beta1.ValidationFailedReason, err.Error()) - return ctrl.Result{}, client.IgnoreNotFound(err) + // 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, v1beta1.InitializedReason) - ctrl.LoggerFrom(ctx).Info("Alert initialized") + conditions.MarkTrue(alert, meta.ReadyCondition, meta.SucceededReason, apiv1.InitializedReason) return ctrl.Result{}, nil } -func (r *AlertReconciler) validate(ctx context.Context, alert *v1beta1.Alert) error { - provider := &v1beta1.Provider{} +func (r *AlertReconciler) isProviderReady(ctx context.Context, alert *apiv1.Alert) error { + provider := &apiv1.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 @@ -203,13 +189,13 @@ func (r *AlertReconciler) validate(ctx context.Context, alert *v1beta1.Alert) er } func (r *AlertReconciler) requestsForProviderChange(o client.Object) []reconcile.Request { - provider, ok := o.(*v1beta1.Provider) + provider, ok := o.(*apiv1.Provider) if !ok { panic(fmt.Errorf("expected a provider, got %T", o)) } ctx := context.Background() - var list v1beta1.AlertList + var list apiv1.AlertList if err := r.List(ctx, &list, client.MatchingFields{ ProviderIndexKey: client.ObjectKeyFromObject(provider).String(), }); err != nil { @@ -223,3 +209,53 @@ func (r *AlertReconciler) requestsForProviderChange(o client.Object) []reconcile return reqs } + +// patch updates the object status, conditions and finalizers. +func (r *AlertReconciler) patch(ctx context.Context, obj *apiv1.Alert, patcher *patch.SerialPatcher) (retErr error) { + // Configure the runtime patcher. + patchOpts := []patch.Option{} + ownedConditions := []string{ + meta.ReadyCondition, + meta.ReconcilingCondition, + meta.StalledCondition, + } + patchOpts = append(patchOpts, + patch.WithOwnedConditions{Conditions: ownedConditions}, + patch.WithForceOverwriteConditions{}, + patch.WithFieldOwner(r.ControllerName), + ) + + // Set the value of the reconciliation request in status. + if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok { + obj.Status.LastHandledReconcileAt = v + } + + // Remove the Reconciling condition and update the observed generation + // if the reconciliation was successful. + if conditions.IsTrue(obj, meta.ReadyCondition) { + conditions.Delete(obj, meta.ReconcilingCondition) + obj.Status.ObservedGeneration = obj.Generation + } + + // Set the Reconciling reason to ProgressingWithRetry if the + // reconciliation has failed. + if conditions.IsFalse(obj, meta.ReadyCondition) && + conditions.Has(obj, meta.ReconcilingCondition) { + rc := conditions.Get(obj, meta.ReconcilingCondition) + rc.Reason = meta.ProgressingWithRetryReason + conditions.Set(obj, rc) + } + + // Patch the object status, conditions and finalizers. + if err := patcher.Patch(ctx, obj, patchOpts...); err != nil { + if !obj.GetDeletionTimestamp().IsZero() { + err = kerrors.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) + } + retErr = kerrors.NewAggregate([]error{retErr, err}) + if retErr != nil { + return retErr + } + } + + return nil +} diff --git a/controllers/event_handling_test.go b/controllers/alert_controller_test.go similarity index 61% rename from controllers/event_handling_test.go rename to controllers/alert_controller_test.go index 8ec6beb5a..0c50f1308 100644 --- a/controllers/event_handling_test.go +++ b/controllers/alert_controller_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package controllers import ( @@ -16,26 +32,131 @@ import ( 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" "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" - notifyv1 "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" "github.com/fluxcd/notification-controller/internal/server" ) -func TestEventHandler(t *testing.T) { - // randomize var? create http server here? +func TestAlertReconciler_Reconcile(t *testing.T) { + g := NewWithT(t) + timeout := 5 * time.Second + resultA := &apiv1.Alert{} + namespaceName := "alert-" + randStringRunes(5) + providerName := "provider-" + randStringRunes(5) + + g.Expect(createNamespace(namespaceName)).NotTo(HaveOccurred(), "failed to create test namespace") + + provider := &apiv1.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: providerName, + Namespace: namespaceName, + }, + Spec: apiv1.ProviderSpec{ + Type: "generic", + Address: "https://webhook.internal", + }, + } + + alert := &apiv1.Alert{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("alert-%s", randStringRunes(5)), + Namespace: namespaceName, + }, + Spec: apiv1.AlertSpec{ + ProviderRef: meta.LocalObjectReference{ + Name: providerName, + }, + EventSeverity: "info", + EventSources: []apiv1.CrossNamespaceObjectReference{ + { + Kind: "Bucket", + Name: "*", + }, + }, + }, + } + g.Expect(k8sClient.Create(context.Background(), alert)).To(Succeed()) + + t.Run("fails with provider not found error", func(t *testing.T) { + g := NewWithT(t) + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA) + return conditions.Has(resultA, meta.ReadyCondition) + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(conditions.IsReady(resultA)).To(BeFalse()) + g.Expect(conditions.GetReason(resultA, meta.ReadyCondition)).To(BeIdenticalTo(meta.FailedReason)) + g.Expect(conditions.GetMessage(resultA, meta.ReadyCondition)).To(ContainSubstring(providerName)) + + g.Expect(conditions.Has(resultA, meta.ReconcilingCondition)).To(BeTrue()) + g.Expect(conditions.GetReason(resultA, meta.ReconcilingCondition)).To(BeIdenticalTo(meta.ProgressingWithRetryReason)) + g.Expect(conditions.GetObservedGeneration(resultA, meta.ReconcilingCondition)).To(BeIdenticalTo(resultA.Generation)) + g.Expect(controllerutil.ContainsFinalizer(resultA, apiv1.NotificationFinalizer)).To(BeTrue()) + }) + + t.Run("recovers when provider exists", func(t *testing.T) { + g := NewWithT(t) + g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), resultA) + return conditions.IsReady(resultA) + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(conditions.GetObservedGeneration(resultA, meta.ReadyCondition)).To(BeIdenticalTo(resultA.Generation)) + g.Expect(resultA.Status.ObservedGeneration).To(BeIdenticalTo(resultA.Generation)) + g.Expect(conditions.Has(resultA, meta.ReconcilingCondition)).To(BeFalse()) + }) + + t.Run("handles reconcileAt", func(t *testing.T) { + g := NewWithT(t) + 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) + 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 *notifyv1.Provider + provider *apiv1.Provider ) g.Expect(createNamespace(namespace)).NotTo(HaveOccurred(), "failed to create test namespace") @@ -67,19 +188,19 @@ func TestEventHandler(t *testing.T) { Name: fmt.Sprintf("provider-%s", randStringRunes(5)), Namespace: namespace, } - provider = ¬ifyv1.Provider{ + provider = &apiv1.Provider{ ObjectMeta: metav1.ObjectMeta{ Name: providerKey.Name, Namespace: providerKey.Namespace, }, - Spec: notifyv1.ProviderSpec{ + Spec: apiv1.ProviderSpec{ Type: "generic", Address: rcvServer.URL, }, } g.Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) g.Eventually(func() bool { - var obj notifyv1.Provider + var obj apiv1.Provider g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), &obj)) return conditions.IsReady(&obj) }, 30*time.Second, time.Second).Should(BeTrue()) @@ -105,17 +226,17 @@ func TestEventHandler(t *testing.T) { Namespace: namespace, } - alert := ¬ifyv1.Alert{ + alert := &apiv1.Alert{ ObjectMeta: metav1.ObjectMeta{ Name: alertKey.Name, Namespace: alertKey.Namespace, }, - Spec: notifyv1.AlertSpec{ + Spec: apiv1.AlertSpec{ ProviderRef: meta.LocalObjectReference{ Name: providerKey.Name, }, EventSeverity: "info", - EventSources: []notifyv1.CrossNamespaceObjectReference{ + EventSources: []apiv1.CrossNamespaceObjectReference{ { Kind: "Bucket", Name: "hyacinth", @@ -149,7 +270,7 @@ func TestEventHandler(t *testing.T) { // wait for controller to mark the alert as ready g.Eventually(func() bool { - var obj notifyv1.Alert + var obj apiv1.Alert g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(alert), &obj)) return conditions.IsReady(&obj) }, 30*time.Second, time.Second).Should(BeTrue()) diff --git a/controllers/provider_controller.go b/controllers/provider_controller.go index f6c6c28fa..42f12bc2f 100644 --- a/controllers/provider_controller.go +++ b/controllers/provider_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +Copyright 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,14 +20,16 @@ import ( "context" "crypto/x509" "fmt" + "net/url" "time" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + 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" @@ -41,7 +43,7 @@ import ( "github.com/fluxcd/pkg/runtime/patch" "github.com/fluxcd/pkg/runtime/predicates" - "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" "github.com/fluxcd/notification-controller/internal/notifier" ) @@ -49,8 +51,9 @@ import ( type ProviderReconciler struct { client.Client helper.Metrics + kuberecorder.EventRecorder - Scheme *runtime.Scheme + ControllerName string } type ProviderReconcilerOptions struct { @@ -64,8 +67,9 @@ func (r *ProviderReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *ProviderReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts ProviderReconcilerOptions) error { return ctrl.NewControllerManagedBy(mgr). - For(&v1beta1.Provider{}). - WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{})). + For(&apiv1.Provider{}, builder.WithPredicates( + predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}), + )). WithOptions(controller.Options{ MaxConcurrentReconciles: opts.MaxConcurrentReconciles, RateLimiter: opts.RateLimiter, @@ -76,96 +80,114 @@ func (r *ProviderReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts P // +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) { - start := time.Now() + reconcileStart := time.Now() log := ctrl.LoggerFrom(ctx) - provider := &v1beta1.Provider{} - if err := r.Get(ctx, req.NamespacedName, provider); err != nil { + obj := &apiv1.Provider{} + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - r.RecordSuspend(ctx, provider, provider.Spec.Suspend) - // return early if the object is suspended - if provider.Spec.Suspend { - log.Info("Reconciliation is suspended for this object") - return ctrl.Result{}, nil - } - - patchHelper, err := patch.NewHelper(provider, r.Client) - if err != nil { - return ctrl.Result{}, err - } + // Initialize the runtime patcher with the current version of the object. + patcher := patch.NewSerialPatcher(obj, r.Client) defer func() { - patchOpts := []patch.Option{ - patch.WithOwnedConditions{ - Conditions: []string{ - meta.ReadyCondition, - meta.ReconcilingCondition, - meta.StalledCondition, - }, - }, - } - - if retErr == nil && (result.IsZero() || !result.Requeue) { - conditions.Delete(provider, meta.ReconcilingCondition) - - patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{}) - - readyCondition := conditions.Get(provider, meta.ReadyCondition) - switch readyCondition.Status { - case metav1.ConditionFalse: - // As we are no longer reconciling and the end-state is not ready, the reconciliation has stalled - conditions.MarkStalled(provider, readyCondition.Reason, readyCondition.Message) - case metav1.ConditionTrue: - // As we are no longer reconciling and the end-state is ready, the reconciliation is no longer stalled - conditions.Delete(provider, meta.StalledCondition) - } + // Patch finalizers, status and conditions. + if err := r.patch(ctx, obj, patcher); err != nil { + retErr = kerrors.NewAggregate([]error{retErr, err}) } - if err := patchHelper.Patch(ctx, provider, patchOpts...); 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()) } - r.Metrics.RecordReadiness(ctx, provider) - r.Metrics.RecordDuration(ctx, provider, start) + // 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.Spec.Interval.Duration.String()) + log.Info(msg) + r.Event(obj, corev1.EventTypeNormal, meta.SucceededReason, msg) + } }() - if !controllerutil.ContainsFinalizer(provider, v1beta1.NotificationFinalizer) { - controllerutil.AddFinalizer(provider, v1beta1.NotificationFinalizer) + if !controllerutil.ContainsFinalizer(obj, apiv1.NotificationFinalizer) { + controllerutil.AddFinalizer(obj, apiv1.NotificationFinalizer) result = ctrl.Result{Requeue: true} return } - if !provider.ObjectMeta.DeletionTimestamp.IsZero() { - controllerutil.RemoveFinalizer(provider, v1beta1.NotificationFinalizer) + if !obj.ObjectMeta.DeletionTimestamp.IsZero() { + controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer) result = ctrl.Result{} return } - return r.reconcile(ctx, provider) + // 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 *v1beta1.Provider) (ctrl.Result, error) { - // Mark the resource as under reconciliation - conditions.MarkReconciling(obj, meta.ProgressingReason, "") +func (r *ProviderReconciler) reconcile(ctx context.Context, obj *apiv1.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 provider spec and credentials - if err := r.validate(ctx, obj); err != nil { - conditions.MarkFalse(obj, meta.ReadyCondition, v1beta1.ValidationFailedReason, err.Error()) - return ctrl.Result{}, 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, v1beta1.InitializedReason) - ctrl.LoggerFrom(ctx).Info("Provider initialized") + conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, apiv1.InitializedReason) + + return ctrl.Result{RequeueAfter: obj.Spec.Interval.Duration}, nil +} + +func (r *ProviderReconciler) validateURLs(provider *apiv1.Provider) error { + address := provider.Spec.Address + proxy := provider.Spec.Proxy - return ctrl.Result{}, nil + if provider.Spec.SecretRef == nil { + if _, err := url.ParseRequestURI(address); err != nil { + return fmt.Errorf("invalid address %s: %w", address, err) + } + if _, err := url.ParseRequestURI(proxy); proxy != "" && err != nil { + return fmt.Errorf("invalid proxy %s: %w", proxy, err) + } + } + return nil } -func (r *ProviderReconciler) validate(ctx context.Context, provider *v1beta1.Provider) error { +func (r *ProviderReconciler) validateCredentials(ctx context.Context, provider *apiv1.Provider) error { address := provider.Spec.Address proxy := provider.Spec.Proxy username := provider.Spec.Username @@ -233,10 +255,66 @@ func (r *ProviderReconciler) validate(ctx context.Context, provider *v1beta1.Pro } } - factory := notifier.NewFactory(address, proxy, username, provider.Spec.Channel, token, headers, certPool, password) + 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 *apiv1.Provider, patcher *patch.SerialPatcher) (retErr error) { + // Configure the runtime patcher. + patchOpts := []patch.Option{} + ownedConditions := []string{ + meta.ReadyCondition, + meta.ReconcilingCondition, + meta.StalledCondition, + } + patchOpts = append(patchOpts, + patch.WithOwnedConditions{Conditions: ownedConditions}, + patch.WithForceOverwriteConditions{}, + patch.WithFieldOwner(r.ControllerName), + ) + + // Set the value of the reconciliation request in status. + if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok { + obj.Status.LastHandledReconcileAt = v + } + + // Remove the Reconciling/Stalled condition and update the observed generation + // if the reconciliation was successful. + if conditions.IsTrue(obj, meta.ReadyCondition) { + conditions.Delete(obj, meta.ReconcilingCondition) + conditions.Delete(obj, meta.StalledCondition) + obj.Status.ObservedGeneration = obj.Generation + } + + // Set the Reconciling reason to ProgressingWithRetry if the + // reconciliation has failed. + if conditions.IsFalse(obj, meta.ReadyCondition) && + conditions.Has(obj, meta.ReconcilingCondition) { + rc := conditions.Get(obj, meta.ReconcilingCondition) + rc.Reason = meta.ProgressingWithRetryReason + conditions.Set(obj, rc) + } + + // Remove the Reconciling condition if the reconciliation has stalled. + if conditions.Has(obj, meta.StalledCondition) { + conditions.Delete(obj, meta.ReconcilingCondition) + } + + // Patch the object status, conditions and finalizers. + if err := patcher.Patch(ctx, obj, patchOpts...); err != nil { + if !obj.GetDeletionTimestamp().IsZero() { + err = kerrors.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) + } + retErr = kerrors.NewAggregate([]error{retErr, err}) + if retErr != nil { + return retErr + } + } + + return nil +} diff --git a/controllers/provider_controller_test.go b/controllers/provider_controller_test.go new file mode 100644 index 000000000..2315daed4 --- /dev/null +++ b/controllers/provider_controller_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "fmt" + "testing" + "time" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "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" + + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" +) + +func TestProviderReconciler_Reconcile(t *testing.T) { + g := NewWithT(t) + timeout := 5 * time.Second + resultP := &apiv1.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 := &apiv1.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: providerKey.Name, + Namespace: providerKey.Namespace, + }, + Spec: apiv1.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()) + g.Expect(resultP.Spec.Interval.Duration).To(BeIdenticalTo(10 * time.Minute)) + }) + + t.Run("fails with secret not found error", func(t *testing.T) { + g := NewWithT(t) + 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) + 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) + resultP.Spec.SecretRef = nil + resultP.Spec.Proxy = "https://proxy.internal|" + g.Expect(k8sClient.Update(context.Background(), resultP)).To(Succeed()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(provider), resultP) + return !conditions.IsReady(resultP) + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(conditions.Has(resultP, meta.ReconcilingCondition)).To(BeFalse()) + g.Expect(conditions.Has(resultP, meta.StalledCondition)).To(BeTrue()) + g.Expect(conditions.GetObservedGeneration(resultP, meta.StalledCondition)).To(BeIdenticalTo(resultP.Generation)) + g.Expect(conditions.GetReason(resultP, meta.StalledCondition)).To(BeIdenticalTo(meta.InvalidURLReason)) + g.Expect(conditions.GetReason(resultP, meta.ReadyCondition)).To(BeIdenticalTo(meta.InvalidURLReason)) + }) + + t.Run("recovers from staleness", func(t *testing.T) { + g := NewWithT(t) + 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) + resultP.Spec.Suspend = true + g.Expect(k8sClient.Update(context.Background(), resultP)).To(Succeed()) + + 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()) + }) +} diff --git a/controllers/receiver_controller.go b/controllers/receiver_controller.go index 28586892a..c403560e5 100644 --- a/controllers/receiver_controller.go +++ b/controllers/receiver_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +Copyright 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,34 +18,38 @@ package controllers import ( "context" - "crypto/sha256" "fmt" "time" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/errors" + 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" "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" - "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" ) // ReceiverReconciler reconciles a Receiver object type ReceiverReconciler struct { client.Client helper.Metrics - Scheme *runtime.Scheme + kuberecorder.EventRecorder + + ControllerName string } type ReceiverReconcilerOptions struct { @@ -53,137 +57,176 @@ type ReceiverReconcilerOptions struct { RateLimiter ratelimiter.RateLimiter } +func (r *ReceiverReconciler) SetupWithManager(mgr ctrl.Manager) error { + return r.SetupWithManagerAndOptions(mgr, ReceiverReconcilerOptions{}) +} + +func (r *ReceiverReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts ReceiverReconcilerOptions) error { + return ctrl.NewControllerManagedBy(mgr). + For(&apiv1.Receiver{}, builder.WithPredicates( + predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}), + )). + WithOptions(controller.Options{ + MaxConcurrentReconciles: opts.MaxConcurrentReconciles, + RateLimiter: opts.RateLimiter, + RecoverPanic: true, + }). + Complete(r) +} + // +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=receivers,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=notification.toolkit.fluxcd.io,resources=receivers/status,verbs=get;update;patch // +kubebuilder:rbac:groups=source.fluxcd.io,resources=buckets,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=source.fluxcd.io,resources=buckets/status,verbs=get // +kubebuilder:rbac:groups=source.fluxcd.io,resources=gitrepositories,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=source.fluxcd.io,resources=gitrepositories/status,verbs=get +// +kubebuilder:rbac:groups=source.fluxcd.io,resources=ocirepositories,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=source.fluxcd.io,resources=ocirepositories/status,verbs=get // +kubebuilder:rbac:groups=source.fluxcd.io,resources=helmrepositories,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=source.fluxcd.io,resources=helmrepositories/status,verbs=get // +kubebuilder:rbac:groups=image.fluxcd.io,resources=imagerepositories,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=image.fluxcd.io,resources=imagerepositories/status,verbs=get +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch func (r *ReceiverReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { - start := time.Now() + reconcileStart := time.Now() log := ctrl.LoggerFrom(ctx) - receiver := &v1beta1.Receiver{} - if err := r.Get(ctx, req.NamespacedName, receiver); err != nil { + obj := &apiv1.Receiver{} + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - // Record suspension metrics - defer r.RecordSuspend(ctx, receiver, receiver.Spec.Suspend) - // Return early if the object is suspended - if receiver.Spec.Suspend { - log.Info("Reconciliation is suspended for this object") - return ctrl.Result{}, nil - } + // Initialize the runtime patcher with the current version of the object. + patcher := patch.NewSerialPatcher(obj, r.Client) - // Initialize the patch helper - patchHelper, err := patch.NewHelper(receiver, r.Client) - if err != nil { - return ctrl.Result{}, err - } defer func() { - // Patch the object, ignoring conflicts on the conditions owned by this controller - patchOpts := []patch.Option{ - patch.WithOwnedConditions{ - Conditions: []string{ - meta.ReadyCondition, - meta.ReconcilingCondition, - meta.StalledCondition, - }, - }, + // Patch finalizers, status and conditions. + if err := r.patch(ctx, obj, patcher); err != nil { + retErr = kerrors.NewAggregate([]error{retErr, err}) } - // Determine if the resource is still being reconciled, or if it has stalled, and record this observation - if retErr == nil && (result.IsZero() || !result.Requeue) { - // We are no longer reconciling - conditions.Delete(receiver, meta.ReconcilingCondition) - - // We have now observed this generation - patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{}) - - readyCondition := conditions.Get(receiver, meta.ReadyCondition) - switch readyCondition.Status { - case metav1.ConditionFalse: - // As we are no longer reconciling and the end-state is not ready, the reconciliation has stalled - conditions.MarkStalled(receiver, readyCondition.Reason, readyCondition.Message) - case metav1.ConditionTrue: - // As we are no longer reconciling and the end-state is ready, the reconciliation is no longer stalled - conditions.Delete(receiver, meta.StalledCondition) - } - } + // Record Prometheus metrics. + r.Metrics.RecordReadiness(ctx, obj) + r.Metrics.RecordDuration(ctx, obj, reconcileStart) + r.Metrics.RecordSuspend(ctx, obj, obj.Spec.Suspend) - // Finally, patch the resource - if err := patchHelper.Patch(ctx, receiver, patchOpts...); err != nil { - retErr = errors.NewAggregate([]error{retErr, err}) + // Emit warning event if the reconciliation failed. + if retErr != nil { + r.Event(obj, corev1.EventTypeWarning, meta.FailedReason, retErr.Error()) } - // Always record readiness and duration metrics - r.Metrics.RecordReadiness(ctx, receiver) - r.Metrics.RecordDuration(ctx, receiver, start) - + // Log and emit success event. + if retErr == nil && conditions.IsReady(obj) { + msg := fmt.Sprintf("Reconciliation finished, next run in %s", obj.Spec.Interval.Duration.String()) + log.Info(msg) + r.Event(obj, corev1.EventTypeNormal, meta.SucceededReason, msg) + } }() - if !controllerutil.ContainsFinalizer(receiver, v1beta1.NotificationFinalizer) { - controllerutil.AddFinalizer(receiver, v1beta1.NotificationFinalizer) + if !controllerutil.ContainsFinalizer(obj, apiv1.NotificationFinalizer) { + controllerutil.AddFinalizer(obj, apiv1.NotificationFinalizer) result = ctrl.Result{Requeue: true} return } - if !receiver.ObjectMeta.DeletionTimestamp.IsZero() { - controllerutil.RemoveFinalizer(receiver, v1beta1.NotificationFinalizer) + if !obj.ObjectMeta.DeletionTimestamp.IsZero() { + controllerutil.RemoveFinalizer(obj, apiv1.NotificationFinalizer) result = ctrl.Result{} return } - return r.reconcile(ctx, receiver) -} - -func (r *ReceiverReconciler) SetupWithManager(mgr ctrl.Manager) error { - return r.SetupWithManagerAndOptions(mgr, ReceiverReconcilerOptions{}) -} + // Return early if the object is suspended. + if obj.Spec.Suspend { + log.Info("Reconciliation is suspended for this object") + return ctrl.Result{}, nil + } -func (r *ReceiverReconciler) SetupWithManagerAndOptions(mgr ctrl.Manager, opts ReceiverReconcilerOptions) error { - return ctrl.NewControllerManagedBy(mgr). - For(&v1beta1.Receiver{}). - WithOptions(controller.Options{ - MaxConcurrentReconciles: opts.MaxConcurrentReconciles, - RateLimiter: opts.RateLimiter, - RecoverPanic: true, - }). - Complete(r) + return r.reconcile(ctx, obj) } // reconcile steps through the actual reconciliation tasks for the object, it returns early on the first step that // produces an error. -func (r *ReceiverReconciler) reconcile(ctx context.Context, obj *v1beta1.Receiver) (ctrl.Result, error) { - // Mark the resource as under reconciliation - conditions.MarkReconciling(obj, meta.ProgressingReason, "") +func (r *ReceiverReconciler) reconcile(ctx context.Context, obj *apiv1.Receiver) (ctrl.Result, error) { + // Mark the resource as under reconciliation. + conditions.MarkReconciling(obj, meta.ProgressingReason, "Reconciliation in progress") token, err := r.token(ctx, obj) if err != nil { - conditions.MarkFalse(obj, meta.ReadyCondition, v1beta1.TokenNotFoundReason, err.Error()) - return ctrl.Result{}, err + conditions.MarkFalse(obj, meta.ReadyCondition, apiv1.TokenNotFoundReason, err.Error()) + obj.Status.URL = "" + obj.Status.WebhookPath = "" + return ctrl.Result{Requeue: true}, err } - receiverURL := fmt.Sprintf("/hook/%s", sha256sum(token+obj.Name+obj.Namespace)) + webhookPath := obj.GetWebhookPath(token) + msg := fmt.Sprintf("Receiver initialized for path: %s", webhookPath) - // Mark the resource as ready and set the URL - conditions.MarkTrue(obj, meta.ReadyCondition, v1beta1.InitializedReason, "Receiver initialized with URL: %s", receiverURL) - obj.Status.URL = receiverURL + // Mark the resource as ready and set the webhook path in status. + conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, msg) - ctrl.LoggerFrom(ctx).Info("Receiver initialized") + if obj.Status.WebhookPath != webhookPath { + obj.Status.URL = webhookPath + obj.Status.WebhookPath = webhookPath + ctrl.LoggerFrom(ctx).Info(msg) + } - return ctrl.Result{}, nil + return ctrl.Result{RequeueAfter: obj.Spec.Interval.Duration}, nil +} + +// patch updates the object status, conditions and finalizers. +func (r *ReceiverReconciler) patch(ctx context.Context, obj *apiv1.Receiver, patcher *patch.SerialPatcher) (retErr error) { + // Configure the runtime patcher. + patchOpts := []patch.Option{} + ownedConditions := []string{ + meta.ReadyCondition, + meta.ReconcilingCondition, + meta.StalledCondition, + } + patchOpts = append(patchOpts, + patch.WithOwnedConditions{Conditions: ownedConditions}, + patch.WithForceOverwriteConditions{}, + patch.WithFieldOwner(r.ControllerName), + ) + + // Set the value of the reconciliation request in status. + if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok { + obj.Status.LastHandledReconcileAt = v + } + + // Remove the Reconciling condition and update the observed generation + // if the reconciliation was successful. + if conditions.IsTrue(obj, meta.ReadyCondition) { + conditions.Delete(obj, meta.ReconcilingCondition) + obj.Status.ObservedGeneration = obj.Generation + } + + // Set the Reconciling reason to ProgressingWithRetry if the + // reconciliation has failed. + if conditions.IsFalse(obj, meta.ReadyCondition) && + conditions.Has(obj, meta.ReconcilingCondition) { + rc := conditions.Get(obj, meta.ReconcilingCondition) + rc.Reason = meta.ProgressingWithRetryReason + conditions.Set(obj, rc) + } + + // Patch the object status, conditions and finalizers. + if err := patcher.Patch(ctx, obj, patchOpts...); err != nil { + if !obj.GetDeletionTimestamp().IsZero() { + err = kerrors.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) + } + retErr = kerrors.NewAggregate([]error{retErr, err}) + if retErr != nil { + return retErr + } + } + + return nil } // token extract the token value from the secret object -func (r *ReceiverReconciler) token(ctx context.Context, receiver *v1beta1.Receiver) (string, error) { +func (r *ReceiverReconciler) token(ctx context.Context, receiver *apiv1.Receiver) (string, error) { token := "" secretName := types.NamespacedName{ Namespace: receiver.GetNamespace(), @@ -204,8 +247,3 @@ func (r *ReceiverReconciler) token(ctx context.Context, receiver *v1beta1.Receiv return token, nil } - -func sha256sum(val string) string { - digest := sha256.Sum256([]byte(val)) - return fmt.Sprintf("%x", digest) -} diff --git a/controllers/receiver_controller_test.go b/controllers/receiver_controller_test.go index eacba5f50..07c33efa5 100644 --- a/controllers/receiver_controller_test.go +++ b/controllers/receiver_controller_test.go @@ -1,11 +1,25 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package controllers import ( "context" "fmt" "net/http" - "os" - "strings" "testing" "time" @@ -13,21 +27,165 @@ import ( 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/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" logf "sigs.k8s.io/controller-runtime/pkg/log" "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/ssa" - notifyv1 "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" "github.com/fluxcd/notification-controller/internal/server" ) -func TestReceiverHandler(t *testing.T) { +func TestReceiverReconciler_Reconcile(t *testing.T) { + g := NewWithT(t) + timeout := 5 * time.Second + resultR := &apiv1.Receiver{} + namespaceName := "receiver-" + randStringRunes(5) + secretName := "secret-" + randStringRunes(5) + + g.Expect(createNamespace(namespaceName)).NotTo(HaveOccurred(), "failed to create test namespace") + + 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()) + + receiverKey := types.NamespacedName{ + Name: fmt.Sprintf("receiver-%s", randStringRunes(5)), + Namespace: namespaceName, + } + receiver := &apiv1.Receiver{ + ObjectMeta: metav1.ObjectMeta{ + Name: receiverKey.Name, + Namespace: receiverKey.Namespace, + }, + Spec: apiv1.ReceiverSpec{ + Type: "generic", + Events: []string{"push"}, + Resources: []apiv1.CrossNamespaceObjectReference{ + { + Name: "podinfo", + Kind: "GitRepository", + }, + }, + SecretRef: meta.LocalObjectReference{ + Name: secretName, + }, + }, + } + g.Expect(k8sClient.Create(context.Background(), receiver)).To(Succeed()) + + t.Run("reports ready status", func(t *testing.T) { + g := NewWithT(t) + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return resultR.Status.ObservedGeneration == resultR.Generation + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(conditions.IsReady(resultR)).To(BeTrue()) + g.Expect(conditions.GetReason(resultR, meta.ReadyCondition)).To(BeIdenticalTo(meta.SucceededReason)) + + g.Expect(conditions.Has(resultR, meta.ReconcilingCondition)).To(BeFalse()) + g.Expect(controllerutil.ContainsFinalizer(resultR, apiv1.NotificationFinalizer)).To(BeTrue()) + g.Expect(resultR.Spec.Interval.Duration).To(BeIdenticalTo(10 * time.Minute)) + }) + + t.Run("fails with secret not found error", func(t *testing.T) { + g := NewWithT(t) + g.Expect(k8sClient.Delete(context.Background(), secret)).To(Succeed()) + + reconcileRequestAt := metav1.Now().String() + resultR.SetAnnotations(map[string]string{ + meta.ReconcileRequestAnnotation: reconcileRequestAt, + }) + g.Expect(k8sClient.Update(context.Background(), resultR)).To(Succeed()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return !conditions.IsReady(resultR) + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(conditions.GetReason(resultR, meta.ReadyCondition)).To(BeIdenticalTo(apiv1.TokenNotFoundReason)) + g.Expect(conditions.GetMessage(resultR, meta.ReadyCondition)).To(ContainSubstring(secretName)) + + g.Expect(conditions.Has(resultR, meta.ReconcilingCondition)).To(BeTrue()) + g.Expect(conditions.GetReason(resultR, meta.ReconcilingCondition)).To(BeIdenticalTo(meta.ProgressingWithRetryReason)) + g.Expect(conditions.GetObservedGeneration(resultR, meta.ReconcilingCondition)).To(BeIdenticalTo(resultR.Generation)) + }) + + t.Run("recovers when secret exists", func(t *testing.T) { + g := NewWithT(t) + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespaceName, + }, + StringData: map[string]string{ + "token": "test", + }, + } + g.Expect(k8sClient.Create(context.Background(), newSecret)).To(Succeed()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return conditions.IsReady(resultR) + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(conditions.GetObservedGeneration(resultR, meta.ReadyCondition)).To(BeIdenticalTo(resultR.Generation)) + g.Expect(resultR.Status.ObservedGeneration).To(BeIdenticalTo(resultR.Generation)) + g.Expect(conditions.Has(resultR, meta.ReconcilingCondition)).To(BeFalse()) + }) + + t.Run("handles reconcileAt", func(t *testing.T) { + g := NewWithT(t) + reconcileRequestAt := metav1.Now().String() + resultR.SetAnnotations(map[string]string{ + meta.ReconcileRequestAnnotation: reconcileRequestAt, + }) + g.Expect(k8sClient.Update(context.Background(), resultR)).To(Succeed()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return resultR.Status.LastHandledReconcileAt == reconcileRequestAt + }, timeout, time.Second).Should(BeTrue()) + }) + + t.Run("finalizes suspended object", func(t *testing.T) { + g := NewWithT(t) + resultR.Spec.Suspend = true + g.Expect(k8sClient.Update(context.Background(), resultR)).To(Succeed()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return resultR.Spec.Suspend == true + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(k8sClient.Delete(context.Background(), resultR)).To(Succeed()) + + g.Eventually(func() bool { + err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return apierrors.IsNotFound(err) + }, timeout, time.Second).Should(BeTrue()) + }) +} + +func TestReceiverReconciler_EventHandler(t *testing.T) { g := NewWithT(t) + timeout := 30 * time.Second + resultR := &apiv1.Receiver{} receiverServer := server.NewReceiverServer("127.0.0.1:56788", logf.Log, k8sClient) receiverMdlw := middleware.New(middleware.Config{ @@ -77,15 +235,15 @@ func TestReceiverHandler(t *testing.T) { Name: fmt.Sprintf("test-receiver-%s", randStringRunes(5)), } - receiver := ¬ifyv1.Receiver{ + receiver := &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: receiverKey.Name, Namespace: receiverKey.Namespace, }, - Spec: notifyv1.ReceiverSpec{ + Spec: apiv1.ReceiverSpec{ Type: "generic", Events: []string{"pull"}, - Resources: []notifyv1.CrossNamespaceObjectReference{ + Resources: []apiv1.CrossNamespaceObjectReference{ { Name: "podinfo", Kind: "GitRepository", @@ -101,44 +259,45 @@ func TestReceiverHandler(t *testing.T) { address := fmt.Sprintf("/hook/%s", sha256sum(token+receiverKey.Name+receiverKey.Namespace)) - var rcvrObj notifyv1.Receiver - g.Eventually(func() bool { - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), &rcvrObj)) - return rcvrObj.Status.URL == address - }, 30*time.Second, time.Second).Should(BeTrue()) - - // Update receiver and check that url doesn't change - rcvrObj.Spec.Events = []string{"ping", "push"} - g.Expect(k8sClient.Update(context.Background(), &rcvrObj)).To(Succeed()) - g.Consistently(func() bool { - var obj notifyv1.Receiver - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), &obj)).To(Succeed()) - return obj.Status.URL == address - }, 30*time.Second, time.Second).Should(BeTrue()) - - res, err := http.Post("http://localhost:56788/"+address, "application/json", nil) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(res.StatusCode).To(Equal(http.StatusOK)) - g.Eventually(func() bool { - obj := &unstructured.Unstructured{} - obj.SetGroupVersionKind(object.GroupVersionKind()) - g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(object), obj)).To(Succeed()) - v, ok := obj.GetAnnotations()[meta.ReconcileRequestAnnotation] - return ok && v != "" - }, 30*time.Second, time.Second).Should(BeTrue()) -} + t.Run("generates URL when ready", func(t *testing.T) { + g := NewWithT(t) + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return conditions.IsReady(resultR) + }, timeout, time.Second).Should(BeTrue()) -func readManifest(manifest, namespace string) (*unstructured.Unstructured, error) { - data, err := os.ReadFile(manifest) - if err != nil { - return nil, err - } - yml := fmt.Sprintf(string(data), namespace) + g.Expect(resultR.Status.URL).To(BeIdenticalTo(address)) + g.Expect(resultR.Status.WebhookPath).To(BeIdenticalTo(address)) + g.Expect(conditions.GetMessage(resultR, meta.ReadyCondition)).To(ContainSubstring(address)) + }) - object, err := ssa.ReadObject(strings.NewReader(yml)) - if err != nil { - return nil, err - } + t.Run("doesn't update the URL on spec updates", func(t *testing.T) { + g := NewWithT(t) + resultR.Spec.Events = []string{"ping", "push"} + g.Expect(k8sClient.Update(context.Background(), resultR)).To(Succeed()) + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR) + return resultR.Status.ObservedGeneration == resultR.Generation + }, timeout, time.Second).Should(BeTrue()) - return object, nil + g.Expect(conditions.IsReady(resultR)) + g.Expect(resultR.Status.URL).To(BeIdenticalTo(address)) + g.Expect(resultR.Status.WebhookPath).To(BeIdenticalTo(address)) + }) + + t.Run("handles event", func(t *testing.T) { + g := NewWithT(t) + res, err := http.Post("http://localhost:56788/"+address, "application/json", nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(res.StatusCode).To(Equal(http.StatusOK)) + + g.Eventually(func() bool { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(object.GroupVersionKind()) + g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(object), obj)).To(Succeed()) + v, ok := obj.GetAnnotations()[meta.ReconcileRequestAnnotation] + return ok && v != "" + }, timeout, time.Second).Should(BeTrue()) + }) } diff --git a/controllers/suite_test.go b/controllers/suite_test.go index e022146f3..7f7458a7d 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -1,5 +1,5 @@ /* -Copyright 2020, 2021 The Flux authors +Copyright 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,10 +18,12 @@ package controllers import ( "context" + "crypto/sha256" "fmt" "math/rand" "os" "path/filepath" + "strings" "testing" "github.com/fluxcd/pkg/runtime/controller" @@ -29,6 +31,7 @@ import ( "github.com/fluxcd/pkg/ssa" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/cli-utils/pkg/kstatus/polling" @@ -36,7 +39,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" - notifyv1 "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" // +kubebuilder:scaffold:imports ) @@ -49,7 +52,7 @@ var ( func TestMain(m *testing.M) { var err error - utilruntime.Must(notifyv1.AddToScheme(scheme.Scheme)) + utilruntime.Must(apiv1.AddToScheme(scheme.Scheme)) //utilruntime.Must(sourcev1.AddToScheme(scheme.Scheme)) testEnv = testenv.New(testenv.WithCRDPath( @@ -61,26 +64,34 @@ func TestMain(m *testing.M) { panic(fmt.Sprintf("failed to create k8s client: %v", err)) } + controllerName := "notification-controller" testMetricsH := controller.MustMakeMetrics(testEnv) - //controllerName := "notification-controller" - reconciler := AlertReconciler{ - Client: testEnv, - Metrics: testMetricsH, - } - if err := (reconciler).SetupWithManager(testEnv); err != nil { + + if err := (&AlertReconciler{ + Client: testEnv, + Metrics: testMetricsH, + ControllerName: controllerName, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + }).SetupWithManager(testEnv); err != nil { panic(fmt.Sprintf("Failed to start AlerReconciler: %v", err)) } if err := (&ProviderReconciler{ - Client: testEnv, + Client: testEnv, + Metrics: testMetricsH, + ControllerName: controllerName, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), }).SetupWithManager(testEnv); err != nil { - panic(fmt.Sprintf("Failed to start PRoviderReconciler: %v", err)) + panic(fmt.Sprintf("Failed to start ProviderReconciler: %v", err)) } if err := (&ReceiverReconciler{ - Client: testEnv, + Client: testEnv, + Metrics: testMetricsH, + ControllerName: controllerName, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), }).SetupWithManager(testEnv); err != nil { - panic(fmt.Sprintf("Failed to start PRoviderReconciler: %v", err)) + panic(fmt.Sprintf("Failed to start ReceiverReconciler: %v", err)) } go func() { @@ -98,8 +109,8 @@ func TestMain(m *testing.M) { poller := polling.NewStatusPoller(k8sClient, restMapper, polling.Options{}) owner := ssa.Owner{ - Field: "notification-controller", - Group: "notification-controller", + Field: controllerName, + Group: controllerName, } manager = ssa.NewResourceManager(k8sClient, poller, owner) @@ -131,3 +142,23 @@ func createNamespace(name string) error { } return k8sClient.Create(context.Background(), namespace) } + +func readManifest(manifest, namespace string) (*unstructured.Unstructured, error) { + data, err := os.ReadFile(manifest) + if err != nil { + return nil, err + } + yml := fmt.Sprintf(string(data), namespace) + + object, err := ssa.ReadObject(strings.NewReader(yml)) + if err != nil { + return nil, err + } + + return object, nil +} + +func sha256sum(val string) string { + digest := sha256.Sum256([]byte(val)) + return fmt.Sprintf("%x", digest) +} diff --git a/docs/api/notification.md b/docs/api/notification.md index 1033671a8..cc2837b5e 100644 --- a/docs/api/notification.md +++ b/docs/api/notification.md @@ -2,20 +2,20 @@

Packages:

-

notification.toolkit.fluxcd.io/v1beta1

-

Package v1beta1 contains API Schema definitions for the notification v1beta1 API group

+

notification.toolkit.fluxcd.io/v1beta2

+

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

Resource Types: -

Alert +

Alert

Alert is the Schema for the alerts API

@@ -33,7 +33,7 @@ Resource Types: apiVersion
string -notification.toolkit.fluxcd.io/v1beta1 +notification.toolkit.fluxcd.io/v1beta2 @@ -63,7 +63,7 @@ Refer to the Kubernetes API documentation for the fields of the spec
- + AlertSpec @@ -82,7 +82,7 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference -

Send events using this provider.

+

ProviderRef specifies which Provider this Alert should use.

@@ -94,7 +94,7 @@ string (Optional) -

Filter events based on severity, defaults to (‘info’). +

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

@@ -102,13 +102,14 @@ If set to ‘info’ no events will be filtered.

eventSources
- + []CrossNamespaceObjectReference -

Filter events based on the involved objects.

+

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

@@ -120,7 +121,8 @@ If set to ‘info’ no events will be filtered.

(Optional) -

A list of Golang regular expressions to be used for excluding messages.

+

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

@@ -132,7 +134,7 @@ string (Optional) -

Short description of the impact and affected cluster.

+

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

@@ -144,8 +146,8 @@ bool (Optional) -

This flag tells the controller to suspend subsequent events dispatching. -Defaults to false.

+

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

@@ -155,7 +157,7 @@ Defaults to false.

status
- + AlertStatus @@ -167,9 +169,9 @@ AlertStatus
-

Provider +

Provider

-

Provider is the Schema for the providers API

+

Provider is the Schema for the providers API.

@@ -185,7 +187,7 @@ AlertStatus apiVersion
string @@ -215,7 +217,7 @@ Refer to the Kubernetes API documentation for the fields of the + + + + @@ -244,7 +260,7 @@ string @@ -256,7 +272,7 @@ string @@ -268,7 +284,7 @@ string @@ -282,7 +298,7 @@ Kubernetes meta/v1.Duration @@ -294,7 +310,7 @@ string @@ -308,8 +324,8 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference @@ -323,8 +339,8 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference @@ -336,8 +352,8 @@ bool
-notification.toolkit.fluxcd.io/v1beta1 +notification.toolkit.fluxcd.io/v1beta2
spec
- + ProviderSpec @@ -232,7 +234,21 @@ string
-

Type of provider

+

Type specifies which Provider implementation to use.

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

Interval at which to reconcile the Provider with its Secret references.

(Optional) -

Alert channel for this provider

+

Channel specifies the destination channel where events should be posted.

(Optional) -

Bot username for this provider

+

Username specifies the name under which events are posted.

(Optional) -

HTTP/S webhook address of this provider

+

Address specifies the HTTP/S incoming webhook address of this Provider.

(Optional) -

Timeout for sending alerts to the provider.

+

Timeout for sending alerts to the Provider.

(Optional) -

HTTP/S address of the proxy

+

Proxy the HTTP/S address of the proxy server.

(Optional) -

Secret reference containing the provider webhook URL -using “address” as data key

+

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

(Optional) -

CertSecretRef can be given the name of a secret containing -a PEM-encoded CA certificate (caFile)

+

CertSecretRef specifies the Secret containing +a PEM-encoded CA certificate (caFile).

(Optional) -

This flag tells the controller to suspend subsequent events handling. -Defaults to false.

+

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

@@ -347,7 +363,7 @@ Defaults to false.

status
- + ProviderStatus @@ -359,9 +375,9 @@ ProviderStatus
-

Receiver +

Receiver

-

Receiver is the Schema for the receivers API

+

Receiver is the Schema for the receivers API.

@@ -377,7 +393,7 @@ ProviderStatus apiVersion
string @@ -407,7 +423,7 @@ Refer to the Kubernetes API documentation for the fields of the + + + + @@ -445,7 +475,7 @@ e.g. ‘push’ for GitHub or ‘Push Hook’ for GitLab.

@@ -477,8 +507,8 @@ bool
-notification.toolkit.fluxcd.io/v1beta1 +notification.toolkit.fluxcd.io/v1beta2
spec
- + ReceiverSpec @@ -430,6 +446,20 @@ the validation procedure and payload deserialization.

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

Interval at which to reconcile the Receiver with its Secret references.

+
events
[]string @@ -437,7 +467,7 @@ the validation procedure and payload deserialization.

(Optional) -

A list of events to handle, +

Events specifies the list of event types to handle, e.g. ‘push’ for GitHub or ‘Push Hook’ for GitLab.

resources
- + []CrossNamespaceObjectReference @@ -464,8 +494,8 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
-

Secret reference containing the token used -to validate the payload authenticity

+

SecretRef specifies the Secret containing the token used +to validate the payload authenticity.

(Optional) -

This flag tells the controller to suspend subsequent events handling. -Defaults to false.

+

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

@@ -488,7 +518,7 @@ Defaults to false.

status
- + ReceiverStatus @@ -500,13 +530,13 @@ ReceiverStatus
-

AlertSpec +

AlertSpec

(Appears on: -Alert) +Alert)

-

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

+

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

@@ -527,7 +557,7 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference @@ -539,7 +569,7 @@ string @@ -547,13 +577,14 @@ If set to ‘info’ no events will be filtered.

@@ -565,7 +596,8 @@ If set to ‘info’ no events will be filtered.

@@ -577,7 +609,7 @@ string @@ -589,21 +621,21 @@ bool
-

Send events using this provider.

+

ProviderRef specifies which Provider this Alert should use.

(Optional) -

Filter events based on severity, defaults to (‘info’). +

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

eventSources
- + []CrossNamespaceObjectReference
-

Filter events based on the involved objects.

+

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

(Optional) -

A list of Golang regular expressions to be used for excluding messages.

+

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

(Optional) -

Short description of the impact and affected cluster.

+

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

(Optional) -

This flag tells the controller to suspend subsequent events dispatching. -Defaults to false.

+

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

-

AlertStatus +

AlertStatus

(Appears on: -Alert) +Alert)

-

AlertStatus defines the observed state of Alert

+

AlertStatus defines the observed state of the Alert.

@@ -616,6 +648,21 @@ Defaults to false.

+ + + + @@ -643,12 +691,12 @@ int64
+ReconcileRequestStatus
+ + +github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus + + +
+

+(Members of ReconcileRequestStatus are embedded into this type.) +

+
conditions
@@ -625,6 +672,7 @@ Defaults to false.

(Optional) +

Conditions holds the conditions for the Alert.

-

CrossNamespaceObjectReference +

CrossNamespaceObjectReference

(Appears on: -AlertSpec, -ReceiverSpec) +AlertSpec, +ReceiverSpec)

CrossNamespaceObjectReference contains enough information to let you locate the typed referenced object at cluster level

@@ -671,7 +719,7 @@ string (Optional) -

API version of the referent

+

API version of the referent.

@@ -682,7 +730,7 @@ string -

Kind of the referent

+

Kind of the referent.

@@ -693,7 +741,7 @@ string -

Name of the referent

+

Name of the referent.

@@ -705,7 +753,7 @@ string (Optional) -

Namespace of the referent

+

Namespace of the referent.

@@ -726,13 +774,13 @@ operator is “In”, and the values array contains only “value&rd -

ProviderSpec +

ProviderSpec

(Appears on: -Provider) +Provider)

-

ProviderSpec defines the desired state of Provider

+

ProviderSpec defines the desired state of the Provider.

@@ -751,7 +799,21 @@ string + + + + @@ -763,7 +825,7 @@ string @@ -775,7 +837,7 @@ string @@ -787,7 +849,7 @@ string @@ -801,7 +863,7 @@ Kubernetes meta/v1.Duration @@ -813,7 +875,7 @@ string @@ -827,8 +889,8 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference @@ -842,8 +904,8 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference @@ -855,21 +917,21 @@ bool
-

Type of provider

+

Type specifies which Provider implementation to use.

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

Interval at which to reconcile the Provider with its Secret references.

(Optional) -

Alert channel for this provider

+

Channel specifies the destination channel where events should be posted.

(Optional) -

Bot username for this provider

+

Username specifies the name under which events are posted.

(Optional) -

HTTP/S webhook address of this provider

+

Address specifies the HTTP/S incoming webhook address of this Provider.

(Optional) -

Timeout for sending alerts to the provider.

+

Timeout for sending alerts to the Provider.

(Optional) -

HTTP/S address of the proxy

+

Proxy the HTTP/S address of the proxy server.

(Optional) -

Secret reference containing the provider webhook URL -using “address” as data key

+

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

(Optional) -

CertSecretRef can be given the name of a secret containing -a PEM-encoded CA certificate (caFile)

+

CertSecretRef specifies the Secret containing +a PEM-encoded CA certificate (caFile).

(Optional) -

This flag tells the controller to suspend subsequent events handling. -Defaults to false.

+

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

-

ProviderStatus +

ProviderStatus

(Appears on: -Provider) +Provider)

-

ProviderStatus defines the observed state of Provider

+

ProviderStatus defines the observed state of the Provider.

@@ -882,14 +944,17 @@ Defaults to false.

@@ -903,19 +968,32 @@ int64 + + + +
-observedGeneration
+ReconcileRequestStatus
-int64 + +github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus +
-(Optional) -

ObservedGeneration is the last reconciled generation.

+

+(Members of ReconcileRequestStatus are embedded into this type.) +

(Optional) +

Conditions holds the conditions for the Provider.

+
+observedGeneration
+ +int64 + +
+(Optional) +

ObservedGeneration is the last reconciled generation.

-

ReceiverSpec +

ReceiverSpec

(Appears on: -Receiver) +Receiver)

-

ReceiverSpec defines the desired state of Receiver

+

ReceiverSpec defines the desired state of the Receiver.

@@ -940,6 +1018,20 @@ the validation procedure and payload deserialization.

+ + + + @@ -955,7 +1047,7 @@ e.g. ‘push’ for GitHub or ‘Push Hook’ for GitLab.

@@ -987,21 +1079,21 @@ bool
+interval
+ + +Kubernetes meta/v1.Duration + + +
+(Optional) +

Interval at which to reconcile the Receiver with its Secret references.

+
events
[]string @@ -947,7 +1039,7 @@ the validation procedure and payload deserialization.

(Optional) -

A list of events to handle, +

Events specifies the list of event types to handle, e.g. ‘push’ for GitHub or ‘Push Hook’ for GitLab.

resources
- + []CrossNamespaceObjectReference @@ -974,8 +1066,8 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
-

Secret reference containing the token used -to validate the payload authenticity

+

SecretRef specifies the Secret containing the token used +to validate the payload authenticity.

(Optional) -

This flag tells the controller to suspend subsequent events handling. -Defaults to false.

+

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

-

ReceiverStatus +

ReceiverStatus

(Appears on: -Receiver) +Receiver)

-

ReceiverStatus defines the observed state of Receiver

+

ReceiverStatus defines the observed state of the Receiver.

@@ -1014,6 +1106,21 @@ Defaults to false.

+ + + + @@ -1034,7 +1142,21 @@ string + + + + @@ -1047,7 +1169,7 @@ int64 diff --git a/docs/spec/README.md b/docs/spec/README.md index 4b32684f8..0b8c0cb98 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -34,9 +34,9 @@ events are processed and where to dispatch them. Notification API: -* [Provider](v1beta1/provider.md) -* [Alert](v1beta1/alert.md) -* [Event](v1beta1/event.md) +* [Alerts](v1beta2/alerts.md) +* [Providers](v1beta2/providers.md) +* [Events](v1beta2/events.md) The alert delivery method is **at-most once** with a timeout of 15 seconds. The controller performs automatic retries for connection errors and 500-range response code. @@ -52,7 +52,7 @@ to be accessed by GitHub, GitLab, Bitbucket, Harbor, DockerHub, Jenkins, Quay, e Receiver API: -* [Receiver](v1beta1/receiver.md) +* [Receivers](v1beta2/receivers.md) When a `Receiver` is created, the controller sets the `Receiver` status to Ready and generates a URL in the format `/hook/sha256sum(token+name+namespace)`. @@ -64,74 +64,3 @@ When the controller receives a POST request: * validates the signature using the `token` secret * extract the event type from the payload * triggers a reconciliation for `spec.resources` if the event type matches one of the `spec.events` items - -## Example - -After installing notification-controller, we can configure alerting for events issued -by source-controller and kustomize-controller. - -Create a notification provider for Slack: - -```yaml -apiVersion: notification.toolkit.fluxcd.io/v1beta1 -kind: Provider -metadata: - name: slack -spec: - type: slack - channel: prod-alerts - secretRef: - name: slack-url ---- -apiVersion: v1 -kind: Secret -metadata: - name: slack-url -data: - address: -``` - -Create an alert for a list of GitRepositories and Kustomizations: - -```yaml -apiVersion: notification.toolkit.fluxcd.io/v1beta1 -kind: Alert -metadata: - name: on-call-webapp -spec: - providerRef: - name: slack - eventSeverity: info - eventSources: - - kind: GitRepository - name: '*' - - kind: Kustomization - name: webapp-frontend - - kind: Kustomization - name: webapp-backend -``` - -Based on the above configuration, the controller will post messages on Slack every time there is an event -issued for the webapp Git repository and Kustomizations. - -Kustomization apply event example: - -```json -{ - "severity": "info", - "ts": "2020-09-17T07:27:11.921Z", - "reportingController": "kustomize-controller", - "reason": "ApplySucceed", - "message": "Kustomization applied in 1.4s, revision: master/a1afe267b54f38b46b487f6e938a6fd508278c07", - "involvedObject": { - "kind": "Kustomization", - "name": "webapp-backend", - "namespace": "default" - }, - "metadata": { - "service/backend": "created", - "deployment.apps/backend": "created", - "horizontalpodautoscaler.autoscaling/backend": "created" - } -} -``` diff --git a/docs/spec/v1beta2/README.md b/docs/spec/v1beta2/README.md new file mode 100644 index 000000000..f853575ef --- /dev/null +++ b/docs/spec/v1beta2/README.md @@ -0,0 +1,14 @@ +# notification.toolkit.fluxcd.io/v1beta2 + +This is the v1beta2 API specification for defining events handling and dispatching. + +## Specification + +* [Alerts](alerts.md) +* [Events](events.md) +* [Providers](providers.md) +* [Receivers](receivers.md) + +## Go Client + +* [github.com/fluxcd/pkg/recorder](https://github.com/fluxcd/pkg/tree/main/recorder) diff --git a/docs/spec/v1beta2/alerts.md b/docs/spec/v1beta2/alerts.md new file mode 100644 index 000000000..83b9709dd --- /dev/null +++ b/docs/spec/v1beta2/alerts.md @@ -0,0 +1,257 @@ +# 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/v1beta2 +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/v1beta2 +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 + ``` + +3. Run `kubectl -n flux-system describe alert slack` to see its status: + + ```console + ... + Status: + Conditions: + Last Transition Time: 2022-11-16T23:43:38Z + Message: Initialized + Observed Generation: 1 + Reason: Succeeded + Status: True + Type: Ready + Observed Generation: 1 + Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal Succeeded 82s notification-controller Initialized + ``` + +## 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 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. + +#### 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/v1beta2 +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 +``` + +### 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. + +## Alert Status + +### Conditions + +An Alert enters various states during its lifecycle, reflected as +[Kubernetes Conditions][typical-status-properties]. +It can be [ready](#ready-alert), or it can [fail during +reconciliation](#failed-alert). + +The Alert API is compatible with the [kstatus specification][kstatus-spec], +and reports the `Reconciling` condition where applicable. + +#### Ready Alert + +The notification-controller marks an Alert as _ready_ when it has the following +characteristics: + +- The Alert's Provider referenced in `.spec.providerRef.name` is found on the cluster. +- The Alert's Provider `Ready` status condition is set to `True`. + +When the Alert is "ready", the controller sets a Condition with the following +attributes in the Alert's `.status.conditions`: + +- `type: Ready` +- `status: "True"` +- `reason: Succeeded` + +#### Failed Alert + +The notification-controller may get stuck trying to reconcile an Alert if its Provider +can't be found or if the Provider is not ready. + +When this happens, the controller sets the `Ready` Condition status to `False`, +and adds a Condition with the following attributes: + +- `type: Reconciling` +- `status: "True"` +- `reason: ProgressingWithRetry` + +### Observed Generation + +The notification-controller reports an +[observed generation][typical-status-properties] +in the Alert's `.status.observedGeneration`. The observed generation is the +latest `.metadata.generation` which resulted in a [ready state](#ready-alert). + +### Last Handled Reconcile At + +The notification-controller reports the last `reconcile.fluxcd.io/requestedAt` +annotation value it acted on in the `.status.lastHandledReconcileAt` field. + +[typical-status-properties]: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties +[kstatus-spec]: https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus diff --git a/docs/spec/v1beta2/events.md b/docs/spec/v1beta2/events.md new file mode 100644 index 000000000..286fe3a98 --- /dev/null +++ b/docs/spec/v1beta2/events.md @@ -0,0 +1,62 @@ +# 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/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", + "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/v1beta2/providers.md b/docs/spec/v1beta2/providers.md new file mode 100644 index 000000000..37a5da45e --- /dev/null +++ b/docs/spec/v1beta2/providers.md @@ -0,0 +1,1407 @@ +# 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 + ``` + +3. Run `kubectl -n flagger-system describe provider slack-bot` to see its status: + + ```console + ... + Status: + Conditions: + Last Transition Time: 2022-11-16T23:43:38Z + Message: Initialized + Observed Generation: 1 + Reason: Succeeded + Status: True + Type: Ready + Observed Generation: 1 + Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal Succeeded 82s notification-controller Reconciliation finished, next run in 10m + ``` + +## 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` | +| [Discord](#discord) | `discord` | +| [GitHub dispatch](#github-dispatch) | `githubdispatch` | +| [Google Chat](#google-chat) | `googlechat` | +| [Grafana](#grafana) | `grafana` | +| [Lark](#lark) | `lark` | +| [Matrix](#matrix) | `matrix` | +| [Microsoft Teams](#microsoft-teams) | `msteams` | +| [Opsgenie](#opsgenie) | `opsgenie` | +| [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` | +| [GitHub](#github) | `github` | +| [GitLab](#gitlab) | `gitlab` | + +#### 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", sigHdr[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[0] { + return fmt.Errorf("HMACs do not match: %#v != %#v", sum, sigHdr[0]) + } + 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 := ioutil.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 := "" + if err := verifySignature(r.Header.Get("X-Signature"), body, key); err != nil { + http.Error(w, fmt.Sprintf("failed to verify HMAC signature: %s", err.String()), 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: slack-webhook +--- +apiVersion: v1 +kind: Secret +metadata: + name: msteams-webhook + namespace: default +stringData: + address: "https://xxx.webhook.office.com/..." +``` + +##### 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/... +``` + +##### 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 +``` + +##### 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 URL where the events are posted. + +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. + +#### Example + +To enable notification-controller to communicate with a provider API over HTTPS +using a self-signed TLS certificate, set the `caFile` 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 +stringData: + caFile: | + <--- CA Key ---> +``` + +### 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. + +### Interval + +`.spec.interval` is a required field with a default of ten minutes that specifies +the time interval at which the controller reconciles the provider with its Secret +references. + +### 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= +``` + +#### 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 gitlab-token --from-literal=token=: +``` + +#### 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 github-token --from-literal=token= +``` + +## Provider Status + +### Conditions + +An Provider enters various states during its lifecycle, reflected as +[Kubernetes Conditions][typical-status-properties]. +It can be [ready](#ready-provider), [stalled](#stalled-provider), or it can [fail during +reconciliation](#failed-provider). + +The Provider API is compatible with the [kstatus specification][kstatus-spec], +and reports `Reconciling` and `Stalled` conditions where applicable to +provide better (timeout) support to solutions polling the Provider to become +`Ready`. + +#### Ready Provider + +The notification-controller marks a Provider as _ready_ when it has the following +characteristics: + +- The Provider's address and proxy are well-formatted URLs. +- The Provider's referenced Secrets are found on the cluster. +- The Provider's referenced Secrets contain valid keys and values. + +When the Provider is "ready", the controller sets a Condition with the following +attributes in the Provider's `.status.conditions`: + +- `type: Ready` +- `status: "True"` +- `reason: Succeeded` + +#### Stalled Provider + +The notification-controller may not be able to reconcile a Provider due to miss-configuration. +This can occur due to some of the following factors: + +- The specified address and/or proxy is not a valid URL. +- The specified proxy is not a valid URL. + +When this happens, the controller sets the `Ready` Condition status to `False`, +and adds a Condition with the following attributes: + +- `type: Stalling` +- `status: "True"` +- `reason: InvalidURL` + +This condition has a ["negative polarity"][typical-status-properties], +and is only present on the Provider while the status value is `"True"`. + +#### Failed Provider + +The notification-controller may get stuck trying to reconcile a Provider. +This can occur due to some of the following factors: + +- The [Secret reference](#secret-reference) contains a reference to a + non-existing Secret. +- The credentials in the referenced Secret are invalid. +- The [TLS Secret reference](#tls-certificates) contains a reference to a + non-existing Secret. +- The TLS certs in the referenced Secret are invalid. + +When this happens, the controller sets the `Ready` Condition status to `False`, +and adds a Condition with the following attributes: + +- `type: Reconciling` +- `status: "True"` +- `reason: ProgressingWithRetry` + +While the Provider has this Condition, the controller will continue to attempt +to reconcile it with an exponential backoff, until +it succeeds and the Provider is marked as [ready](#ready-provider). + +### Observed Generation + +The notification-controller reports an +[observed generation][typical-status-properties] +in the Provider's `.status.observedGeneration`. The observed generation is the +latest `.metadata.generation` which resulted in a [ready state](#ready-provider). + +### Last Handled Reconcile At + +The notification-controller reports the last `reconcile.fluxcd.io/requestedAt` +annotation value it acted on in the `.status.lastHandledReconcileAt` field. + +[typical-status-properties]: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties +[kstatus-spec]: https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus diff --git a/docs/spec/v1beta2/receivers.md b/docs/spec/v1beta2/receivers.md new file mode 100644 index 000000000..912b29eec --- /dev/null +++ b/docs/spec/v1beta2/receivers.md @@ -0,0 +1,913 @@ +# Incoming Webhook Receivers + +The `Receiver` API defines an incoming webhook receiver that triggers the +reconciliation for a group of Flux Custom Resources. + +## Example + +The following is an example of how to configure an incoming webhook for the +GitHub repository where Flux was bootstrapped with `flux bootstrap github`. +After a Git push, GitHub will send a push event to notification-controller, +which in turn tells Flux to pull and apply the latest changes from upstream. + +**Note:** The following assumes an Ingress exposes the controller's +`webhook-receiver` Kubernetes Service. How to configure the Ingress is out of +scope for this example. + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: github-receiver + namespace: flux-system +spec: + type: github + events: + - "ping" + - "push" + secretRef: + name: receiver-token + resources: + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: GitRepository + name: flux-system +``` + +In the above example: + +- A Receiver named `github-receiver` is created, indicated by the + `.metadata.name` field. +- The notification-controller generates a unique webhook path using the + Receiver name, namespace and the token from the referenced + `.spec.secretRef.name` secret. +- The incoming webhook path is reported in the `.status.webhookPath` field. +- When a GitHub push event is received, the controller verifies the payload's + integrity and authenticity, using [HMAC][] and the `X-Hub-Signature` HTTP + header. +- If the event type matches `.spec.events` and the payload is verified, then + the controller triggers a reconciliation for the `flux-system` GitRepository + which is listed under `.spec.resources`. + +You can run this example by saving the manifest into `github-receiver.yaml`. + +1. Generate a random string and create a Secret with a `token` field: + + ```sh + TOKEN=$(head -c 12 /dev/urandom | shasum | cut -d ' ' -f1) + + kubectl -n flux-system create secret generic receiver-token \ + --from-literal=token=$TOKEN + ``` + +2. Apply the resource on the cluster: + + ```sh + kubectl -n flux-system apply -f github-receiver.yaml + ``` + +3. Run `kubectl -n flux-system describe receiver github-receiver` to see its status: + + ```console + ... + Status: + Conditions: + Last Transition Time: 2022-11-16T23:43:38Z + Message: Receiver initialised for path: /hook/bed6d00b5555b1603e1f59b94d7fdbca58089cb5663633fb83f2815dc626d92b + Observed Generation: 1 + Reason: Succeeded + Status: True + Type: Ready + Observed Generation: 1 + Webhook Path: /hook/bed6d00b5555b1603e1f59b94d7fdbca58089cb5663633fb83f2815dc626d92b + Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal Succeeded 82s notification-controller Reconciliation finished, next run in 10m + ``` + +4. Run `kubectl -n flux-system get receivers` to see the generated webhook path: + + ```console + NAME READY STATUS + github-receiver True Receiver initialised for path: /hook/bed6d00b5555b1603e1f59b94d7fdbca58089cb5663633fb83f2815dc626d92b + ``` + +5. On GitHub, navigate to your repository and click on the "Add webhook" button + under "Settings/Webhooks". Fill the form with: + + - **Payload URL**: The composed address, consisting of the Ingress' hostname + exposing the controller's `webhook-receiver` Kubernetes Service, and the + generated path for the Receiver. For this example: + `https:///hook/bed6d00b5555b1603e1f59b94d7fdbca58089cb5663633fb83f2815dc626d92b` + - **Secret**: The `token` string generated in step 1. + +## Writing a Receiver spec + +As with all other Kubernetes config, a Receiver needs `apiVersion`, +`kind`, and `metadata` fields. The name of a Receiver object must be a +valid [DNS subdomain name](https://kubernetes.io/docs/concepts/overview/working-with-objects/names#dns-subdomain-names). + +A Receiver 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 how the controller should +handle the incoming webhook request. + +#### Supported Receiver types + +| Receiver | Type | Supports filtering using [Events](#events) | +|--------------------------------------------|----------------|--------------------------------------------| +| [Generic webhook](#generic) | `generic` | ❌ | +| [Generic webhook with HMAC](#generic-hmac) | `generic-hmac` | ❌ | +| [GitHub](#github) | `github` | ✅ | +| [Gitea](#github) | `github` | ✅ | +| [GitLab](#gitlab) | `gitlab` | ✅ | +| [Bitbucket server](#bitbucket-server) | `bitbucket` | ✅ | +| [Harbor](#harbor) | `harbor` | ❌ | +| [DockerHub](#dockerhub) | `dockerhub` | ❌ | +| [Quay](#quay) | `quay` | ❌ | +| [Nexus](#nexus) | `nexus` | ❌ | +| [Azure Container Registry](#acr) | `acr` | ❌ | +| [Google Container Registry](#gcr) | `gcr` | ❌ | + +#### Generic + +When a Receiver's `.spec.type` is set to `generic`, the controller will respond +to any HTTP request to the generated [`.status.webhookPath` path](#webhook-path), +and request a reconciliation for all listed [Resources](#resources). + +**Note:** This type of Receiver does not perform any validation on the incoming +request, and it does not support filtering using [Events](#events). + +##### Generic example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: generic-receiver + namespace: default +spec: + type: generic + secretRef: + name: webhook-token + resources: + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: GitRepository + name: webapp + namespace: default +``` + +#### Generic HMAC + +When a Receiver's `.spec.type` is set to `generic-hmac`, the controller will +respond to any HTTP request to the generated [`.status.webhookPath` path](#webhook-path), +while verifying the request's payload integrity and authenticity using [HMAC][]. + +The controller uses the `X-Signature` header to get the hash signature. This +signature should be prefixed with the hash function (`sha1`, `sha256` or +`sha512`) used to generate the signature, in the following format: +`=`. + +To validate the HMAC signature, the controller will use the `token` string +from the [Secret reference](#secret-reference) to generate a hash signature +using the same hash function as the one specified in the `X-Signature` header. + +If the generated hash signature matches the one specified in the `X-Signature` +header, the controller will request a reconciliation for all listed +[Resources](#resources). + +**Note:** This type of Receiver does not support filtering using +[Events](#events). + +##### Generic HMAC example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: generic-hmac-receiver + namespace: default +spec: + type: generic-hmac + secretRef: + name: webhook-token + resources: + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: GitRepository + name: webapp + namespace: default +``` + +##### HMAC signature generation example + +1. Generate the HMAC hash for the request body using OpenSSL: + + ```sh + printf '' | openssl dgst -sha1 -r -hmac "" | awk '{print $1}' + ``` + + You can replace the `-sha1` flag with `-sha256` or `-sha512` to use a + different hash function. + +2. Send an HTTP POST request with the body and the HMAC hash to the webhook URL: + + ```sh + curl -X POST -H "X-Signature: =" -d '' + ``` + +#### GitHub + +When a Receiver's `.spec.type` is set to `github`, the controller will respond +to an [HTTP webhook event payload](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads) +from GitHub to the generated [`.status.webhookPath` path](#webhook-path), +while verifying the payload using [HMAC][]. + +The controller uses the [`X-Hub-Signature` header](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#delivery-headers) +from the request made by GitHub to get the hash signature. To enable the +inclusion of this header, the `token` string from the [Secret reference](#secret-reference) +must be configured as the [secret token for the +webhook](https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks#setting-your-secret-token). + +The controller will calculate the HMAC hash signature for the received request +payload using the same `token` string, and compare it with the one specified in +the header. If the two signatures match, the controller will request a +reconciliation for all listed [Resources](#resources). + +This type of Receiver offers the ability to filter incoming events by comparing +the `X-GitHub-Event` header to the list of [Events](#events). +For a list of available events, see the [GitHub +documentation](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads). + +##### GitHub example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: github-receiver + namespace: default +spec: + type: github + events: + - "ping" + - "push" + secretRef: + name: webhook-token + resources: + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: GitRepository + name: webapp +``` + +The above example makes use of the [`.spec.events` field](#events) to filter +incoming events from GitHub, instructing the controller to only respond to +[`ping`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#ping) +and [`push`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push) +events. + +#### Gitea + +For Gitea, the `.spec.type` field can be set to `github` as it produces [GitHub +type](#github) compatible [webhook event payloads](https://docs.gitea.io/en-us/webhooks/). + +**Note:** While the payloads are compatible with the GitHub type, the number of +available events may be limited and/or different from the ones available in +GitHub. Refer to the [Gitea source code](https://github.com/go-gitea/gitea/blob/main/models/webhook/hooktask.go#L28) +to see the list of available [events](#events). + +#### GitLab + +When a Receiver's `.spec.type` is set to `gitlab`, the controller will respond +to an [HTTP webhook event payload](https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#events) +from GitLab to the generated [`.status.webhookPath` path](#webhook-path). + +The controller validates the payload's authenticity by comparing the +[`X-Gitlab-Token` header](https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#validate-payloads-by-using-a-secret-token) +from the request made by GitLab to the `token` string from the [Secret +reference](#secret-reference). To enable the inclusion of this header, the +`token` string must be configured as the "Secret token" while [configuring a +webhook in GitLab](https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#configure-a-webhook-in-gitlab). + +If the two tokens match, the controller will request a reconciliation for all +listed [Resources](#resources). + +This type of Receiver offers the ability to filter incoming events by comparing +the `X-Gitlab-Event` header to the list of [Events](#events). For a list of +available webhook types, refer to the [GitLab +documentation](https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html). + +##### GitLab example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: gitlab-receiver + namespace: default +spec: + type: gitlab + events: + - "Push Hook" + - "Tag Push Hook" + secretRef: + name: webhook-token + resources: + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: GitRepository + name: webapp-frontend + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: GitRepository + name: webapp-backend +``` + +The above example makes use of the [`.spec.events` field](#events) to filter +incoming events from GitLab, instructing the controller to only respond to +[`Push Hook`](https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#push-events) +and [`Tag Push Hook`](https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#tag-events) +events. + +#### Bitbucket Server + +When a Receiver's `.spec.type` is set to `bitbucket`, the controller will +respond to an [HTTP webhook event payload](https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html) +from Bitbucket Server to the generated [`.status.webhookPath` path](#webhook-path), +while verifying the payload's integrity and authenticity using [HMAC][]. + +The controller uses the [`X-Hub-Signature` header](https://confluence.atlassian.com/bitbucketserver/manage-webhooks-938025878.html#Managewebhooks-webhooksecrets) +from the request made by BitBucket Server to get the hash signature. To enable +the inclusion of this header, the `token` string from the [Secret +reference](#secret-reference) must be configured as the "Secret" while creating +a webhook in Bitbucket Server. + +The controller will calculate the HMAC hash signature for the received request +payload using the same `token` string, and compare it with the one specified in +the header. If the two signatures match, the controller will request a +reconciliation for all listed [Resources](#resources). + +This type of Receiver offers the ability to filter incoming events by comparing +the `X-Event-Key` header to the list of [Events](#events). For a list of +available event keys, refer to the [Bitbucket Server +documentation](https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html#Eventpayload-Repositoryevents). + +**Note:** Bitbucket Cloud does not support signing webhook requests +([BCLOUD-14683](https://jira.atlassian.com/browse/BCLOUD-14683), +[BCLOUD-12195](https://jira.atlassian.com/browse/BCLOUD-12195)). If your +repositories are on Bitbucket Cloud, you will need to use a [Generic +Receiver](#generic) instead. + +##### Bitbucket Server example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: bitbucket-receiver + namespace: default +spec: + type: bitbucket + events: + - "repo:refs_changed" + secretRef: + name: webhook-token + resources: + - apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: GitRepository + name: webapp +``` + +The above example makes use of the [`.spec.events` field](#events) to filter +incoming events from Bitbucket Server, instructing the controller to only +respond to [`repo:refs_changed` (Push)](https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html#Eventpayload-Push) +events. + +#### Harbor + +When a Receiver's `.spec.type` is set to `harbor`, the controller will respond +to an [HTTP webhook event payload](https://goharbor.io/docs/latest/working-with-projects/project-configuration/configure-webhooks/#payload-format) +from Harbor to the generated [`.status.webhookPath` path](#webhook-path). + +The controller validates the payload's authenticity by comparing the +`Authorization` header from the request made by Harbor to the `token` string +from the [Secret reference](#secret-reference). To enable the inclusion of this +header, the `token` string must be configured as the "Auth Header" while +[configuring a webhook in +Harbor](https://goharbor.io/docs/latest/working-with-projects/project-configuration/configure-webhooks/#configure-webhooks). + +If the two tokens match, the controller will request a reconciliation for all +listed [Resources](#resources). + +**Note:** This type of Receiver does not support filtering using +[Events](#events). However, Harbor does support configuring event types for +which a webhook will be triggered. + +##### Harbor example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: harbor-receiver + namespace: default +spec: + type: harbor + secretRef: + name: webhook-token + resources: + - apiVersion: image.toolkit.fluxcd.io/v1beta1 + kind: ImageRepository + name: webapp +``` + +#### DockerHub + +When a Receiver's `.spec.type` is set to `dockerhub`, the controller will +respond to an [HTTP webhook event payload](https://docs.docker.com/docker-hub/webhooks/) +from DockerHub to the generated [`.status.webhookPath` path](#webhook-path). + +The controller performs minimal validation of the payload by attempting to +unmarshal the [JSON request body](https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload). +If the unmarshalling is successful, the controller will request a reconciliation +for all listed [Resources](#resources). + +**Note:** This type of Receiver does not support filtering using +[Events](#events). + +##### DockerHub example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: dockerhub-receiver + namespace: default +spec: + type: dockerhub + secretRef: + name: webhook-token + resources: + - apiVersion: image.toolkit.fluxcd.io/v1beta1 + kind: ImageRepository + name: webapp +``` + +#### Quay + +When a Receiver's `.spec.type` is set to `quay`, the controller will respond to +an HTTP [Repository Push Notification payload](https://docs.quay.io/guides/notifications.html#repository-push) +from Quay to the generated [`.status.webhookPath` path](#webhook-path). + +The controller performs minimal validation of the payload by attempting to +unmarshal the JSON request body to the expected format. If the unmarshalling is +successful, the controller will request a reconciliation for all listed +[Resources](#resources). + +**Note:** This type of Receiver does not support filtering using +[Events](#events). In addition, it does not support any "Repository +Notification" other than "Repository Push". + +##### Quay example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: quay-receiver + namespace: default +spec: + type: quay + secretRef: + name: webhook-token + resources: + - apiVersion: image.toolkit.fluxcd.io/v1beta1 + kind: ImageRepository + name: webapp +``` + +#### Nexus + +When a Receiver's `.spec.type` is set to `nexus`, the controller will respond +to an [HTTP webhook event payload](https://help.sonatype.com/repomanager3/integrations/webhooks/example-headers-and-payloads) +from Nexus Repository Manager 3 to the generated [`.status.webhookPath` +path](#webhook-path), while verifying the payload's integrity and +authenticity using [HMAC][]. + +The controller validates the payload by comparing the +[`X-Nexus-Webhook-Signature` header](https://help.sonatype.com/repomanager3/integrations/webhooks/working-with-hmac-payloads) +from the request made by Nexus to the `token` string from the [Secret +reference](#secret-reference). To enable the inclusion of this header, the +`token` string must be configured as the "Secret Key" while [enabling a +repository webhook capability](https://help.sonatype.com/repomanager3/integrations/webhooks/enabling-a-repository-webhook-capability). + +The controller will calculate the HMAC hash signature for the received request +payload using the same `token` string, and compare it with the one specified in +the header. If the two signatures match, the controller will attempt to +unmarshal the request body to the expected format. If the unmarshalling is +successful, the controller will request a reconciliation for all listed +[Resources](#resources). + +**Note:** This type of Receiver does not support filtering using +[Events](#events). + +##### Nexus example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: nexus-receiver + namespace: default +spec: + type: nexus + secretRef: + name: webhook-token + resources: + - apiVersion: image.toolkit.fluxcd.io/v1beta1 + kind: ImageRepository + name: webapp +``` + +#### GCR + +When a Receiver's `.spec.type` is set to `gcr`, the controller will respond to +an [HTTP webhook event payload](https://cloud.google.com/container-registry/docs/configuring-notifications#notification_examples) +from Google Cloud Registry to the generated [`.status.webhookPath`](#webhook-path), +while verifying the payload is legitimate using [JWT](https://cloud.google.com/pubsub/docs/push#authentication). + +The controller verifies the request originates from Google by validating the +token from the [`Authorization` header](https://cloud.google.com/pubsub/docs/push#validate_tokens). +For this to work, authentication must be enabled for the Pub/Sub subscription, +refer to the [Google Cloud documentation](https://cloud.google.com/pubsub/docs/push#configure_for_push_authentication) +for more information. + +When the verification succeeds, the request payload is unmarshalled to the +expected format. If this is successful, the controller will request a +reconciliation for all listed [Resources](#resources). + +**Note:** This type of Receiver does not support filtering using +[Events](#events). + +##### GCR example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: gcr-receiver + namespace: default +spec: + type: gcr + secretRef: + name: webhook-token + resources: + - apiVersion: image.toolkit.fluxcd.io/v1beta1 + kind: ImageRepository + name: webapp + namespace: default +``` + +#### ACR + +When a Receiver's `.spec.type` is set to `acr`, the controller will respond to +an [HTTP webhook event payload](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-webhook-reference), +from Azure Container Registry to the generated [`.status.webhookPath`](#webhook-path). + +The controller performs minimal validation of the payload by attempting to +unmarshal the JSON request body. If the unmarshalling is successful, the +controller will request a reconciliation for all listed [Resources](#resources). + +**Note:** This type of Receiver does not support filtering using +[Events](#events). However, Azure Container Registry does [support configuring +webhooks to only send events for specific actions](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-webhook#create-webhook---azure-portal). + +##### ACR example + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: acr-receiver + namespace: default +spec: + type: acr + secretRef: + name: webhook-token + resources: + - kind: ImageRepository + name: webapp +``` + +### Events + +`.spec.events` in an optional field to specify a list of webhook payload event +types this Receiver should act on. If left empty, no filtering is applied and +any (valid) payload is handled. + +**Note:** Support for this field, and the entries in it, is dependent on the +Receiver type. See the [supported Receiver types](#supported-receiver-types) +section for more information. + +### Resources + +`.spec.resources` is a required field to specify which Flux Custom Resources +should be reconciled when the Receiver's [webhook path](#webhook-path) is +called. + +A resource entry contains the following fields: + +- `apiVersion`: The Flux Custom Resource API group and version, such as + `source.toolkit.fluxcd.io/v1beta2`. +- `kind`: The Flux Custom Resource kind, supported values are `Bucket`, + `GitRepository`, `Kustomization`, `HelmRelease`, `HelmChart`, + `HelmRepository`, `ImageRepository`, `ImagePolicy`, `ImageUpdateAutomation` + and `OCIRepository`. +- `name`: The Flux Custom Resource `.metadata.name`. This field may contain + a wildcard `*` to match all resources of the `kind`. +- `namespace` (Optional): The Flux Custom Resource `.metadata.namespace`. + When not specified, the Receiver's `.metadata.namespace` is used instead. + + +**Note:** Cross-namespace references [can be disabled for security +reasons](#disabling-cross-namespace-selectors). + +### Secret reference + +`.spec.secretRef.name` is a required field to specify a name reference to a +Secret in the same namespace as the Receiver. The Secret must contain a `token` +key, whose value is a string containing a (random) secret token. + +This token is used to salt the generated [webhook path](#webhook-path), and +depending on the Receiver [type](#supported-receiver-types), to verify the +authenticity of a request. + +#### Secret example + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: webhook-token + namespace: default +type: Opaque +stringData: + token: +``` + +### Interval + +`.spec.interval` is a required field with a default of ten minutes that specifies +the time interval at which the controller reconciles the provider with its Secret +reference. + +### Suspend + +`.spec.suspend` is an optional field to suspend the Receiver. +When set to `true`, the controller will stop processing events for this Receiver. +When the field is set to `false` or removed, it will resume. + +## Working with Receivers + +### Disabling cross-namespace selectors + +On multi-tenant clusters, platform admins can disable cross-namespace +references with the `--no-cross-namespace-refs=true` flag. When this flag is +set, Receivers can only refer to [Resources](#resources) in the same namespace +as the [Alert](alerts.md) object, preventing tenants from triggering +reconciliations to another tenant's resources. + +### Public Ingress considerations + +Considerations should be made when exposing the controller's `webhook-receiver` +Kubernetes Service to the public internet. Each request to a Receiver [webhook +path](#webhook-path) will result in request to the Kubernetes API, as the +controller needs to fetch information about the resource. This endpoint may be +protected with a token, but this does not defend against a situation where a +legitimate webhook caller starts sending large amounts of requests, or the +token is somehow leaked. This may result in the controller, as it may get rate +limited by the Kubernetes API, degrading its functionality. + +It is therefore a good idea to set rate limits on the Ingress which exposes +the Kubernetes Service. If you are using ingress-nginx, this can be done by +[adding annotations](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#rate-limiting). + +### Triggering a reconcile + +To manually tell the notification-controller to reconcile a Receiver outside +of the [specified interval window](#interval), a Receiver can be annotated with +`reconcile.fluxcd.io/requestedAt: `. Annotating the resource +queues the Receiver for reconciliation if the `` differs from +the last value the controller acted on, as reported in +[`.status.lastHandledReconcileAt`](#last-handled-reconcile-at). + +Using `kubectl`: + +```sh +kubectl annotate --field-manager=flux-client-side-apply --overwrite receiver/ reconcile.fluxcd.io/requestedAt="$(date +%s)" +``` + +Using `flux`: + +```sh +flux reconcile source receiver +``` + +### Waiting for `Ready` + +When a change is applied, it is possible to wait for the Receiver to reach a +[ready state](#ready-receiver) using `kubectl`: + +```sh +kubectl wait receiver/ --for=condition=ready --timeout=1m +``` + +### Suspending and resuming + +When you find yourself in a situation where you temporarily want to pause the +reconciliation of a Receiver and the handling of requests, you can suspend it +using the [`.spec.suspend` field](#suspend). + +#### Suspend a Receiver + +In your YAML declaration: + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: +spec: + suspend: true +``` + +Using `kubectl`: + +```sh +kubectl patch receiver --field-manager=flux-client-side-apply -p '{\"spec\": {\"suspend\" : true }}' +``` + +Using `flux`: + +```sh +flux suspend receiver +``` + +#### Resume a Receiver + +In your YAML declaration, comment out (or remove) the field: + +```yaml +--- +apiVersion: notification.toolkit.fluxcd.io/v1beta2 +kind: Receiver +metadata: + name: +spec: + # suspend: true +``` + +**Note:** Setting the field value to `false` has the same effect as removing +it, but does not allow for "hot patching" using e.g. `kubectl` while practicing +GitOps; as the manually applied patch would be overwritten by the declared +state in Git. + +Using `kubectl`: + +```sh +kubectl patch receiver --field-manager=flux-client-side-apply -p '{\"spec\" : {\"suspend\" : false }}' +``` + +Using `flux`: + +```sh +flux resume receiver +``` + +### Debugging a Receiver + +There are several ways to gather information about a Receiver for debugging +purposes. + +#### Describe the Receiver + +Describing a Receiver using `kubectl describe receiver ` displays +the latest recorded information for the resource in the Status and Events +sections: + +```console +... +Status: +... +Status: + Conditions: + Last Transition Time: 2022-11-21T12:41:48Z + Message: Reconciliation in progress + Observed Generation: 1 + Reason: ProgressingWithRetry + Status: True + Type: Reconciling + Last Transition Time: 2022-11-21T12:41:48Z + Message: unable to read token from secret 'default/webhook-token' error: Secret "webhook-token" not found + Observed Generation: 1 + Reason: TokenNotFound + Status: False + Type: Ready + Observed Generation: -1 +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Warning Failed 5s (x4 over 16s) notification-controller unable to read token from secret 'default/webhook-token' error: Secret "webhook-token" not found +``` + +#### Trace emitted Events + +To view events for specific Receiver(s), `kubectl get events` can be used in +combination with `--field-sector` to list the Events for specific objects. +For example, running + +```sh +kubectl get events --field-selector involvedObject.kind=Receiver,involvedObject.name= +``` + +lists + +```console +LAST SEEN TYPE REASON OBJECT MESSAGE +3m44s Warning Failed receiver/ unable to read token from secret 'default/webhook-token' error: Secret "webhook-token" not found +``` + +## Receiver Status + +### Conditions + +A Receiver enters various states during its lifecycle, reflected as +[Kubernetes Conditions][typical-status-properties]. +It can be [ready](#ready-receiver), or it can [fail during +reconciliation](#failed-receiver). + +The Receiver API is compatible with the [kstatus specification][kstatus-spec], +and reports the `Reconciling` condition where applicable. + +#### Ready Receiver + +The notification-controller marks a Receiver as _ready_ when it has the following +characteristics: + +- The Receiver's Secret referenced in `.spec.secretRef.name` is found on the cluster. +- The Receiver's Secret contains a `token` key. + +When the Receiver is "ready", the controller sets a Condition with the following +attributes in the Alert's `.status.conditions`: + +- `type: Ready` +- `status: "True"` +- `reason: Succeeded` + +#### Failed Receiver + +The notification-controller may get stuck trying to reconcile a Receiver if its +secret token can not be found. + +When this happens, the controller sets the `Ready` Condition status to `False`, +and adds a Condition with the following attributes: + +- `type: Reconciling` +- `status: "True"` +- `reason: ProgressingWithRetry` + +### Observed Generation + +The notification-controller reports an +[observed generation][typical-status-properties] +in the Receiver's `.status.observedGeneration`. The observed generation is the +latest `.metadata.generation` which resulted in a [ready state](#ready-receiver). + +### Last Handled Reconcile At + +The notification-controller reports the last `reconcile.fluxcd.io/requestedAt` +annotation value it acted on in the `.status.lastHandledReconcileAt` field. + +### Webhook Path + +When a Receiver becomes [ready](#ready-receiver), the controller reports the +generated incoming webhook path under `.status.webhookPath`. The path format is +`/hook/sha256sum(token+name+namespace)`. + +[typical-status-properties]: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties +[kstatus-spec]: https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus +[HMAC]: https://en.wikipedia.org/wiki/HMAC diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index ae5141121..74dbebc30 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +Copyright 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/notifier/azure_devops.go b/internal/notifier/azure_devops.go index 85ee85991..e42217600 100644 --- a/internal/notifier/azure_devops.go +++ b/internal/notifier/azure_devops.go @@ -35,13 +35,14 @@ const genre string = "fluxcd" // AzureDevOps is a Azure DevOps notifier. type AzureDevOps struct { - Project string - Repo string - Client git.Client + Project string + Repo string + ProviderUID string + Client git.Client } // NewAzureDevOps creates and returns a new AzureDevOps notifier. -func NewAzureDevOps(addr string, token string, certPool *x509.CertPool) (*AzureDevOps, error) { +func NewAzureDevOps(providerUID string, addr string, token string, certPool *x509.CertPool) (*AzureDevOps, error) { if len(token) == 0 { return nil, errors.New("azure devops token cannot be empty") } @@ -71,9 +72,10 @@ func NewAzureDevOps(addr string, token string, certPool *x509.CertPool) (*AzureD Client: *client, } return &AzureDevOps{ - Project: proj, - Repo: repo, - Client: gitClient, + Project: proj, + Repo: repo, + ProviderUID: providerUID, + Client: gitClient, }, nil } @@ -99,7 +101,8 @@ func (a AzureDevOps) Post(ctx context.Context, event eventv1.Event) error { // Check if the exact status is already set g := genre - name, desc := formatNameAndDescription(event) + _, desc := formatNameAndDescription(event) + id := generateCommitStatusID(a.ProviderUID, event) createArgs := git.CreateCommitStatusArgs{ Project: &a.Project, RepositoryId: &a.Repo, @@ -109,7 +112,7 @@ func (a AzureDevOps) Post(ctx context.Context, event eventv1.Event) error { State: &state, Context: &git.GitStatusContext{ Genre: &g, - Name: &name, + Name: &id, }, }, } diff --git a/internal/notifier/azure_devops_fuzz_test.go b/internal/notifier/azure_devops_fuzz_test.go index 25c07c72e..1aaafcce7 100644 --- a/internal/notifier/azure_devops_fuzz_test.go +++ b/internal/notifier/azure_devops_fuzz_test.go @@ -33,13 +33,12 @@ import ( const apiLocations = `{"count":0,"value":[{"area":"","id":"428dd4fb-fda5-4722-af02-9313b80305da","routeTemplate":"","resourceName":"","maxVersion":"6.0","minVersion":"5.0","releasedVersion":"6.0"}]}` func Fuzz_AzureDevOps(f *testing.F) { - f.Add("alakazam", "org/proj/_git/repo", "revision/dsa123a", "error", "", []byte{}, []byte(`{"count":1,"value":[{"state":"error","description":"","context":{"genre":"fluxcd","name":"/"}}]}`)) - f.Add("alakazam", "org/proj/_git/repo", "revision/dsa123a", "info", "", []byte{}, []byte(`{"count":1,"value":[{"state":"info","description":"","context":{"genre":"fluxcd","name":"/"}}]}`)) - f.Add("alakazam", "org/proj/_git/repo", "revision/dsa123a", "info", "", []byte{}, []byte(`{"count":0,"value":[]}`)) - f.Add("alakazam", "org/proj/_git/repo", "", "", "Progressing", []byte{}, []byte{}) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "alakazam", "org/proj/_git/repo", "revision/dsa123a", "error", "", []byte{}, []byte(`{"count":1,"value":[{"state":"error","description":"","context":{"genre":"fluxcd","name":"/"}}]}`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "alakazam", "org/proj/_git/repo", "revision/dsa123a", "info", "", []byte{}, []byte(`{"count":1,"value":[{"state":"info","description":"","context":{"genre":"fluxcd","name":"/"}}]}`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "alakazam", "org/proj/_git/repo", "revision/dsa123a", "info", "", []byte{}, []byte(`{"count":0,"value":[]}`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "alakazam", "org/proj/_git/repo", "", "", "Progressing", []byte{}, []byte{}) - f.Fuzz(func(t *testing.T, - token, urlSuffix, revision, severity, reason string, seed, response []byte) { + f.Fuzz(func(t *testing.T, uuid, token, urlSuffix, revision, severity, reason string, seed, response []byte) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "_apis") { w.Write([]byte(apiLocations)) @@ -55,7 +54,7 @@ func Fuzz_AzureDevOps(f *testing.F) { var cert x509.CertPool _ = fuzz.NewConsumer(seed).GenerateStruct(&cert) - azureDevOps, err := NewAzureDevOps(fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) + azureDevOps, err := NewAzureDevOps(uuid, fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) if err != nil { return } diff --git a/internal/notifier/azure_devops_test.go b/internal/notifier/azure_devops_test.go index 75c49f0e3..0e5ebca24 100644 --- a/internal/notifier/azure_devops_test.go +++ b/internal/notifier/azure_devops_test.go @@ -24,19 +24,19 @@ import ( ) func TestNewAzureDevOpsBasic(t *testing.T) { - a, err := NewAzureDevOps("https://dev.azure.com/foo/bar/_git/baz", "foo", nil) + a, err := NewAzureDevOps("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://dev.azure.com/foo/bar/_git/baz", "foo", nil) assert.Nil(t, err) assert.Equal(t, a.Project, "bar") assert.Equal(t, a.Repo, "baz") } func TestNewAzureDevOpsInvalidUrl(t *testing.T) { - _, err := NewAzureDevOps("https://dev.azure.com/foo/bar/baz", "foo", nil) + _, err := NewAzureDevOps("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://dev.azure.com/foo/bar/baz", "foo", nil) assert.NotNil(t, err) } func TestNewAzureDevOpsMissingToken(t *testing.T) { - _, err := NewAzureDevOps("https://dev.azure.com/foo/bar/baz", "", nil) + _, err := NewAzureDevOps("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://dev.azure.com/foo/bar/baz", "", nil) assert.NotNil(t, err) } diff --git a/internal/notifier/bitbucket.go b/internal/notifier/bitbucket.go index 65dfe9c81..393c7fca2 100644 --- a/internal/notifier/bitbucket.go +++ b/internal/notifier/bitbucket.go @@ -34,13 +34,14 @@ import ( // Bitbucket is a Bitbucket Server notifier. type Bitbucket struct { - Owner string - Repo string - Client *bitbucket.Client + Owner string + Repo string + ProviderUID string + Client *bitbucket.Client } // NewBitbucket creates and returns a new Bitbucket notifier. -func NewBitbucket(addr string, token string, certPool *x509.CertPool) (*Bitbucket, error) { +func NewBitbucket(providerUID string, addr string, token string, certPool *x509.CertPool) (*Bitbucket, error) { if len(token) == 0 { return nil, errors.New("bitbucket token cannot be empty") } @@ -76,9 +77,10 @@ func NewBitbucket(addr string, token string, certPool *x509.CertPool) (*Bitbucke } return &Bitbucket{ - Owner: owner, - Repo: repo, - Client: client, + Owner: owner, + Repo: repo, + ProviderUID: providerUID, + Client: client, }, nil } @@ -103,8 +105,9 @@ func (b Bitbucket) Post(ctx context.Context, event eventv1.Event) error { } name, desc := formatNameAndDescription(event) + id := generateCommitStatusID(b.ProviderUID, event) // key has a limitation of 40 characters in bitbucket api - key := sha1String(name) + key := sha1String(id) cmo := &bitbucket.CommitsOptions{ Owner: b.Owner, diff --git a/internal/notifier/bitbucket_fuzz_test.go b/internal/notifier/bitbucket_fuzz_test.go index e92bf2ce3..d525fc377 100644 --- a/internal/notifier/bitbucket_fuzz_test.go +++ b/internal/notifier/bitbucket_fuzz_test.go @@ -31,12 +31,10 @@ import ( ) func Fuzz_Bitbucket(f *testing.F) { - f.Add("user:pass", "org/repo", "revision/dsa123a", "info", []byte{}, []byte(`{"state":"SUCCESSFUL","description":"","key":"","name":"","url":""}`)) - f.Add("user:pass", "org/repo", "revision/dsa123a", "error", []byte{}, []byte(`{}`)) - - f.Fuzz(func(t *testing.T, - token, urlSuffix, revision, severity string, seed, response []byte) { + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "user:pass", "org/repo", "revision/dsa123a", "info", []byte{}, []byte(`{"state":"SUCCESSFUL","description":"","key":"","name":"","url":""}`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "user:pass", "org/repo", "revision/dsa123a", "error", []byte{}, []byte(`{}`)) + f.Fuzz(func(t *testing.T, uuid, token, urlSuffix, revision, severity string, seed, response []byte) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.Copy(io.Discard, r.Body) w.Write(response) @@ -47,7 +45,7 @@ func Fuzz_Bitbucket(f *testing.F) { var cert x509.CertPool _ = fuzz.NewConsumer(seed).GenerateStruct(&cert) - bitbucket, err := NewBitbucket(fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) + bitbucket, err := NewBitbucket(uuid, fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) if err != nil { return } diff --git a/internal/notifier/bitbucket_test.go b/internal/notifier/bitbucket_test.go index 519506eed..7dcd42d8d 100644 --- a/internal/notifier/bitbucket_test.go +++ b/internal/notifier/bitbucket_test.go @@ -23,18 +23,18 @@ import ( ) func TestNewBitbucketBasic(t *testing.T) { - b, err := NewBitbucket("https://bitbucket.org/foo/bar", "foo:bar", nil) + b, err := NewBitbucket("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://bitbucket.org/foo/bar", "foo:bar", nil) assert.Nil(t, err) assert.Equal(t, b.Owner, "foo") assert.Equal(t, b.Repo, "bar") } func TestNewBitbucketInvalidUrl(t *testing.T) { - _, err := NewBitbucket("https://bitbucket.org/foo/bar/baz", "foo:bar", nil) + _, err := NewBitbucket("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://bitbucket.org/foo/bar/baz", "foo:bar", nil) assert.NotNil(t, err) } func TestNewBitbucketInvalidToken(t *testing.T) { - _, err := NewBitbucket("https://bitbucket.org/foo/bar", "bar", nil) + _, err := NewBitbucket("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://bitbucket.org/foo/bar", "bar", nil) assert.NotNil(t, err) } diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index 91a52d90c..98cf9e63c 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -20,30 +20,40 @@ import ( "crypto/x509" "fmt" - "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" ) type Factory struct { - URL string - ProxyURL string - Username string - Channel string - Token string - Headers map[string]string - CertPool *x509.CertPool - Password string + URL string + ProxyURL string + Username string + Channel string + Token string + Headers map[string]string + CertPool *x509.CertPool + Password string + ProviderUID string } -func NewFactory(url string, proxy string, username string, channel string, token string, headers map[string]string, certPool *x509.CertPool, password string) *Factory { +func NewFactory(url string, + proxy string, + username string, + channel string, + token string, + headers map[string]string, + certPool *x509.CertPool, + password string, + providerUID string) *Factory { return &Factory{ - URL: url, - ProxyURL: proxy, - Channel: channel, - Username: username, - Token: token, - Headers: headers, - CertPool: certPool, - Password: password, + URL: url, + ProxyURL: proxy, + Channel: channel, + Username: username, + Token: token, + Headers: headers, + CertPool: certPool, + Password: password, + ProviderUID: providerUID, } } @@ -55,47 +65,47 @@ func (f Factory) Notifier(provider string) (Interface, error) { var n Interface var err error switch provider { - case v1beta1.GenericProvider: + case apiv1.GenericProvider: n, err = NewForwarder(f.URL, f.ProxyURL, f.Headers, f.CertPool, nil) - case v1beta1.GenericHMACProvider: + case apiv1.GenericHMACProvider: n, err = NewForwarder(f.URL, f.ProxyURL, f.Headers, f.CertPool, []byte(f.Token)) - case v1beta1.SlackProvider: + case apiv1.SlackProvider: n, err = NewSlack(f.URL, f.ProxyURL, f.Token, f.CertPool, f.Username, f.Channel) - case v1beta1.DiscordProvider: + case apiv1.DiscordProvider: n, err = NewDiscord(f.URL, f.ProxyURL, f.Username, f.Channel) - case v1beta1.RocketProvider: + case apiv1.RocketProvider: n, err = NewRocket(f.URL, f.ProxyURL, f.CertPool, f.Username, f.Channel) - case v1beta1.MSTeamsProvider: + case apiv1.MSTeamsProvider: n, err = NewMSTeams(f.URL, f.ProxyURL, f.CertPool) - case v1beta1.GitHubProvider: - n, err = NewGitHub(f.URL, f.Token, f.CertPool) - case v1beta1.GitHubDispatchProvider: + case apiv1.GitHubProvider: + n, err = NewGitHub(f.ProviderUID, f.URL, f.Token, f.CertPool) + case apiv1.GitHubDispatchProvider: n, err = NewGitHubDispatch(f.URL, f.Token, f.CertPool) - case v1beta1.GitLabProvider: - n, err = NewGitLab(f.URL, f.Token, f.CertPool) - case v1beta1.BitbucketProvider: - n, err = NewBitbucket(f.URL, f.Token, f.CertPool) - case v1beta1.AzureDevOpsProvider: - n, err = NewAzureDevOps(f.URL, f.Token, f.CertPool) - case v1beta1.GoogleChatProvider: + case apiv1.GitLabProvider: + n, err = NewGitLab(f.ProviderUID, f.URL, f.Token, f.CertPool) + case apiv1.BitbucketProvider: + n, err = NewBitbucket(f.ProviderUID, f.URL, f.Token, f.CertPool) + case apiv1.AzureDevOpsProvider: + n, err = NewAzureDevOps(f.ProviderUID, f.URL, f.Token, f.CertPool) + case apiv1.GoogleChatProvider: n, err = NewGoogleChat(f.URL, f.ProxyURL) - case v1beta1.WebexProvider: + case apiv1.WebexProvider: n, err = NewWebex(f.URL, f.ProxyURL, f.CertPool, f.Channel, f.Token) - case v1beta1.SentryProvider: + case apiv1.SentryProvider: n, err = NewSentry(f.CertPool, f.URL, f.Channel) - case v1beta1.AzureEventHubProvider: + case apiv1.AzureEventHubProvider: n, err = NewAzureEventHub(f.URL, f.Token, f.Channel) - case v1beta1.TelegramProvider: + case apiv1.TelegramProvider: n, err = NewTelegram(f.Channel, f.Token) - case v1beta1.LarkProvider: + case apiv1.LarkProvider: n, err = NewLark(f.URL) - case v1beta1.Matrix: + case apiv1.Matrix: n, err = NewMatrix(f.URL, f.Token, f.Channel, f.CertPool) - case v1beta1.OpsgenieProvider: + case apiv1.OpsgenieProvider: n, err = NewOpsgenie(f.URL, f.ProxyURL, f.CertPool, f.Token) - case v1beta1.AlertManagerProvider: + case apiv1.AlertManagerProvider: n, err = NewAlertmanager(f.URL, f.ProxyURL, f.CertPool) - case v1beta1.GrafanaProvider: + case apiv1.GrafanaProvider: n, err = NewGrafana(f.URL, f.ProxyURL, f.Token, f.CertPool, f.Username, f.Password) default: err = fmt.Errorf("provider %s not supported", provider) diff --git a/internal/notifier/forwarder_test.go b/internal/notifier/forwarder_test.go index d0f6815b4..dfc53b814 100644 --- a/internal/notifier/forwarder_test.go +++ b/internal/notifier/forwarder_test.go @@ -25,10 +25,10 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" - "github.com/stretchr/testify/require" ) func TestForwarder_New(t *testing.T) { diff --git a/internal/notifier/github.go b/internal/notifier/github.go index 580dbb81a..b83e2b661 100644 --- a/internal/notifier/github.go +++ b/internal/notifier/github.go @@ -34,12 +34,13 @@ import ( ) type GitHub struct { - Owner string - Repo string - Client *github.Client + Owner string + Repo string + ProviderUID string + Client *github.Client } -func NewGitHub(addr string, token string, certPool *x509.CertPool) (*GitHub, error) { +func NewGitHub(providerUID string, addr string, token string, certPool *x509.CertPool) (*GitHub, error) { if len(token) == 0 { return nil, errors.New("github token cannot be empty") } @@ -81,9 +82,10 @@ func NewGitHub(addr string, token string, certPool *x509.CertPool) (*GitHub, err } return &GitHub{ - Owner: comp[0], - Repo: comp[1], - Client: client, + Owner: comp[0], + Repo: comp[1], + ProviderUID: providerUID, + Client: client, }, nil } @@ -106,11 +108,12 @@ func (g *GitHub) Post(ctx context.Context, event eventv1.Event) error { if err != nil { return err } - name, desc := formatNameAndDescription(event) + _, desc := formatNameAndDescription(event) + id := generateCommitStatusID(g.ProviderUID, event) status := &github.RepoStatus{ State: &state, - Context: &name, + Context: &id, Description: &desc, } diff --git a/internal/notifier/github_dispatch_test.go b/internal/notifier/github_dispatch_test.go index 0841f7d16..370fb0580 100644 --- a/internal/notifier/github_dispatch_test.go +++ b/internal/notifier/github_dispatch_test.go @@ -22,6 +22,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" ) func TestNewGitHubDispatchBasic(t *testing.T) { @@ -55,7 +57,7 @@ func TestGitHubDispatch_PostUpdate(t *testing.T) { require.NoError(t, err) event := testEvent() - event.Metadata["commit_status"] = "update" + event.Metadata[eventv1.MetaCommitStatusKey] = eventv1.MetaCommitStatusUpdateValue err = githubDispatch.Post(context.TODO(), event) require.NoError(t, err) } diff --git a/internal/notifier/github_fuzz_test.go b/internal/notifier/github_fuzz_test.go index 9f615b669..651860430 100644 --- a/internal/notifier/github_fuzz_test.go +++ b/internal/notifier/github_fuzz_test.go @@ -30,15 +30,14 @@ import ( ) func Fuzz_GitHub(f *testing.F) { - f.Add("token", "org/repo", "revision/abce1", "error", "", []byte{}, []byte(`[{"context":"/","state":"failure","description":""}]`)) - f.Add("token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"context":"/","state":"success","description":""}]`)) - f.Add("token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"context":"/","state":"failure","description":""}]`)) - f.Add("token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"context":"/"}]`)) - f.Add("token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{}]`)) - f.Add("token", "org/repo", "revision/abce1", "info", "Progressing", []byte{}, []byte{}) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "error", "", []byte{}, []byte(`[{"context":"/","state":"failure","description":""}]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"context":"/","state":"success","description":""}]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"context":"/","state":"failure","description":""}]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"context":"/"}]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{}]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "Progressing", []byte{}, []byte{}) - f.Fuzz(func(t *testing.T, - token, urlSuffix, revision, severity, reason string, seed, response []byte) { + f.Fuzz(func(t *testing.T, uuid, token, urlSuffix, revision, severity, reason string, seed, response []byte) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(response) io.Copy(io.Discard, r.Body) @@ -49,7 +48,7 @@ func Fuzz_GitHub(f *testing.F) { var cert x509.CertPool _ = fuzz.NewConsumer(seed).GenerateStruct(&cert) - github, err := NewGitHub(fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) + github, err := NewGitHub(uuid, fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) if err != nil { return } diff --git a/internal/notifier/github_test.go b/internal/notifier/github_test.go index c320df17d..76532ad0f 100644 --- a/internal/notifier/github_test.go +++ b/internal/notifier/github_test.go @@ -24,7 +24,7 @@ import ( ) func TestNewGitHubBasic(t *testing.T) { - g, err := NewGitHub("https://github.com/foo/bar", "foobar", nil) + g, err := NewGitHub("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://github.com/foo/bar", "foobar", nil) assert.Nil(t, err) assert.Equal(t, g.Owner, "foo") assert.Equal(t, g.Repo, "bar") @@ -32,7 +32,7 @@ func TestNewGitHubBasic(t *testing.T) { } func TestNewEmterpriseGitHubBasic(t *testing.T) { - g, err := NewGitHub("https://foobar.com/foo/bar", "foobar", nil) + g, err := NewGitHub("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://foobar.com/foo/bar", "foobar", nil) assert.Nil(t, err) assert.Equal(t, g.Owner, "foo") assert.Equal(t, g.Repo, "bar") @@ -40,12 +40,12 @@ func TestNewEmterpriseGitHubBasic(t *testing.T) { } func TestNewGitHubInvalidUrl(t *testing.T) { - _, err := NewGitHub("https://github.com/foo/bar/baz", "foobar", nil) + _, err := NewGitHub("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://github.com/foo/bar/baz", "foobar", nil) assert.NotNil(t, err) } func TestNewGitHubEmptyToken(t *testing.T) { - _, err := NewGitHub("https://github.com/foo/bar", "", nil) + _, err := NewGitHub("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://github.com/foo/bar", "", nil) assert.NotNil(t, err) } diff --git a/internal/notifier/gitlab.go b/internal/notifier/gitlab.go index 13c6a69a3..9e976bd66 100644 --- a/internal/notifier/gitlab.go +++ b/internal/notifier/gitlab.go @@ -31,11 +31,12 @@ import ( ) type GitLab struct { - Id string - Client *gitlab.Client + Id string + ProviderUID string + Client *gitlab.Client } -func NewGitLab(addr string, token string, certPool *x509.CertPool) (*GitLab, error) { +func NewGitLab(providerUID string, addr string, token string, certPool *x509.CertPool) (*GitLab, error) { if len(token) == 0 { return nil, errors.New("gitlab token cannot be empty") } @@ -61,8 +62,9 @@ func NewGitLab(addr string, token string, certPool *x509.CertPool) (*GitLab, err } gitlab := &GitLab{ - Id: id, - Client: client, + Id: id, + ProviderUID: providerUID, + Client: client, } return gitlab, nil @@ -88,9 +90,10 @@ func (g *GitLab) Post(ctx context.Context, event eventv1.Event) error { return err } - name, desc := formatNameAndDescription(event) + _, desc := formatNameAndDescription(event) + id := generateCommitStatusID(g.ProviderUID, event) status := &gitlab.CommitStatus{ - Name: name, + Name: id, SHA: rev, Status: string(state), Description: desc, @@ -106,7 +109,7 @@ func (g *GitLab) Post(ctx context.Context, event eventv1.Event) error { } setOpt := &gitlab.SetCommitStatusOptions{ - Name: &name, + Name: &id, Description: &desc, State: state, } diff --git a/internal/notifier/gitlab_fuzz_test.go b/internal/notifier/gitlab_fuzz_test.go index a6d2b4920..fc1d33078 100644 --- a/internal/notifier/gitlab_fuzz_test.go +++ b/internal/notifier/gitlab_fuzz_test.go @@ -30,13 +30,12 @@ import ( ) func Fuzz_GitLab(f *testing.F) { - f.Add("token", "org/repo", "revision/abce1", "error", "", []byte{}, []byte(`[{"sha":"abce1","status":"failed","name":"/","description":""}]`)) - f.Add("token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"sha":"abce1","status":"failed","name":"/","description":""}]`)) - f.Add("token", "org/repo", "revision/abce1", "info", "Progressing", []byte{}, []byte{}) - f.Add("token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "error", "", []byte{}, []byte(`[{"sha":"abce1","status":"failed","name":"/","description":""}]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[{"sha":"abce1","status":"failed","name":"/","description":""}]`)) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "Progressing", []byte{}, []byte{}) + f.Add("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "token", "org/repo", "revision/abce1", "info", "", []byte{}, []byte(`[]`)) - f.Fuzz(func(t *testing.T, - token, urlSuffix, revision, severity, reason string, seed, response []byte) { + f.Fuzz(func(t *testing.T, uuid, token, urlSuffix, revision, severity, reason string, seed, response []byte) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(response) io.Copy(io.Discard, r.Body) @@ -47,7 +46,7 @@ func Fuzz_GitLab(f *testing.F) { var cert x509.CertPool _ = fuzz.NewConsumer(seed).GenerateStruct(&cert) - gitLab, err := NewGitLab(fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) + gitLab, err := NewGitLab(uuid, fmt.Sprintf("%s/%s", ts.URL, urlSuffix), token, &cert) if err != nil { return } diff --git a/internal/notifier/gitlab_test.go b/internal/notifier/gitlab_test.go index 32a50bdf9..753e37cca 100644 --- a/internal/notifier/gitlab_test.go +++ b/internal/notifier/gitlab_test.go @@ -23,25 +23,25 @@ import ( ) func TestNewGitLabBasic(t *testing.T) { - g, err := NewGitLab("https://gitlab.com/foo/bar", "foobar", nil) + g, err := NewGitLab("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://gitlab.com/foo/bar", "foobar", nil) assert.Nil(t, err) assert.Equal(t, g.Id, "foo/bar") } func TestNewGitLabSubgroups(t *testing.T) { - g, err := NewGitLab("https://gitlab.com/foo/bar/baz", "foobar", nil) + g, err := NewGitLab("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://gitlab.com/foo/bar/baz", "foobar", nil) assert.Nil(t, err) assert.Equal(t, g.Id, "foo/bar/baz") } func TestNewGitLabSelfHosted(t *testing.T) { - g, err := NewGitLab("https://example.com/foo/bar", "foo:bar", nil) + g, err := NewGitLab("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com/foo/bar", "foo:bar", nil) assert.Nil(t, err) assert.Equal(t, g.Id, "foo/bar") assert.Equal(t, g.Client.BaseURL().Host, "example.com") } func TestNewGitLabEmptyToken(t *testing.T) { - _, err := NewGitLab("https://gitlab.com/foo/bar", "", nil) + _, err := NewGitLab("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://gitlab.com/foo/bar", "", nil) assert.NotNil(t, err) } diff --git a/internal/notifier/slack_test.go b/internal/notifier/slack_test.go index e9bcc5a72..9722cfcbf 100644 --- a/internal/notifier/slack_test.go +++ b/internal/notifier/slack_test.go @@ -25,6 +25,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" ) func TestSlack_Post(t *testing.T) { @@ -52,7 +54,7 @@ func TestSlack_PostUpdate(t *testing.T) { require.NoError(t, err) event := testEvent() - event.Metadata["commit_status"] = "update" + event.Metadata[eventv1.MetaCommitStatusKey] = eventv1.MetaCommitStatusUpdateValue err = slack.Post(context.TODO(), event) require.NoError(t, err) } diff --git a/internal/notifier/util.go b/internal/notifier/util.go index 4da9324d3..2034948d1 100644 --- a/internal/notifier/util.go +++ b/internal/notifier/util.go @@ -109,11 +109,11 @@ func splitCamelcase(src string) (entries []string) { func parseRevision(rev string) (string, error) { comp := strings.Split(rev, "/") if len(comp) < 2 { - return "", fmt.Errorf("Revision string format incorrect: %v", rev) + return "", fmt.Errorf("revision string format incorrect: %v", rev) } sha := comp[len(comp)-1] if sha == "" { - return "", fmt.Errorf("Commit Sha cannot be empty: %v", rev) + return "", fmt.Errorf("commit SHA cannot be empty: %v", rev) } return sha, nil } diff --git a/internal/notifier/util_test.go b/internal/notifier/util_test.go index 062000e89..83572b6d2 100644 --- a/internal/notifier/util_test.go +++ b/internal/notifier/util_test.go @@ -19,14 +19,15 @@ package notifier import ( "testing" - eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" "github.com/stretchr/testify/require" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" ) func TestUtil_NameAndDescription(t *testing.T) { event := eventv1.Event{ - InvolvedObject: v1.ObjectReference{ + InvolvedObject: corev1.ObjectReference{ Kind: "Kustomization", Name: "gitops-system", }, @@ -54,13 +55,13 @@ func TestUtil_ParseRevisionNestedBranch(t *testing.T) { func TestUtil_ParseRevisionOneComponents(t *testing.T) { revString := "master" _, err := parseRevision(revString) - require.EqualError(t, err, "Revision string format incorrect: master") + require.EqualError(t, err, "revision string format incorrect: master") } func TestUtil_ParseRevisionTooFewComponents(t *testing.T) { revString := "master/" _, err := parseRevision(revString) - require.EqualError(t, err, "Commit Sha cannot be empty: master/") + require.EqualError(t, err, "commit SHA cannot be empty: master/") } func TestUtil_ParseGitHttps(t *testing.T) { diff --git a/internal/notifier/webex_test.go b/internal/notifier/webex_test.go index e8991e2d5..5a806b657 100644 --- a/internal/notifier/webex_test.go +++ b/internal/notifier/webex_test.go @@ -25,6 +25,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" ) func TestWebex_Post(t *testing.T) { @@ -50,7 +52,7 @@ func TestWebex_PostUpdate(t *testing.T) { require.NoError(t, err) event := testEvent() - event.Metadata["commit_status"] = "update" + event.Metadata[eventv1.MetaCommitStatusKey] = eventv1.MetaCommitStatusUpdateValue err = webex.Post(context.TODO(), event) require.NoError(t, err) } diff --git a/internal/server/event_handlers.go b/internal/server/event_handlers.go index b4b923ec3..e7f9c3cc8 100644 --- a/internal/server/event_handlers.go +++ b/internal/server/event_handlers.go @@ -38,7 +38,7 @@ import ( eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" "github.com/fluxcd/pkg/masktoken" - apiv1 "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" "github.com/fluxcd/notification-controller/internal/notifier" ) @@ -243,7 +243,7 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request) continue } - factory := notifier.NewFactory(webhook, proxy, username, provider.Spec.Channel, token, headers, certPool, password) + 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 { s.logger.Error(err, "failed to initialize provider", diff --git a/internal/server/receiver_handler_test.go b/internal/server/receiver_handler_test.go index 00dbb6548..c28436e40 100644 --- a/internal/server/receiver_handler_test.go +++ b/internal/server/receiver_handler_test.go @@ -23,18 +23,19 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "github.com/google/go-github/v41/github" "net/http/httptest" "testing" + "github.com/google/go-github/v41/github" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - "github.com/fluxcd/notification-controller/api/v1beta1" "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/logger" + + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" ) func Test_validate(t *testing.T) { @@ -48,19 +49,19 @@ func Test_validate(t *testing.T) { hashOpts hashOpts headers map[string]string payload map[string]interface{} - receiver *v1beta1.Receiver + receiver *apiv1.Receiver receiverType string secret *corev1.Secret expectedErr bool }{ { name: "Generic receiver", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "test-receiver", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.GenericReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, SecretRef: meta.LocalObjectReference{ Name: "token", }, @@ -78,12 +79,12 @@ func Test_validate(t *testing.T) { }, { name: "gitlab receiver", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "gitlab-receiver", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.GitLabReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GitLabReceiver, SecretRef: meta.LocalObjectReference{ Name: "token", }, @@ -104,12 +105,12 @@ func Test_validate(t *testing.T) { }, { name: "github receiver", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "test-receiver", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.GitHubReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GitHubReceiver, SecretRef: meta.LocalObjectReference{ Name: "token", }, @@ -137,12 +138,12 @@ func Test_validate(t *testing.T) { }, { name: "generic hmac receiver", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "generic-hmac-receiver", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.GenericHMACReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericHMACReceiver, SecretRef: meta.LocalObjectReference{ Name: "token", }, @@ -167,12 +168,12 @@ func Test_validate(t *testing.T) { }, { name: "bitbucket receiver", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "bitbucket-receiver", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.BitbucketReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.BitbucketReceiver, Events: []string{"push"}, SecretRef: meta.LocalObjectReference{ Name: "token", @@ -199,12 +200,12 @@ func Test_validate(t *testing.T) { }, { name: "quay receiver", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "quay-receiver", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.QuayReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.QuayReceiver, SecretRef: meta.LocalObjectReference{ Name: "token", }, @@ -228,12 +229,12 @@ func Test_validate(t *testing.T) { }, { name: "harbor receiver", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "harbor-receiver", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.HarborReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.HarborReceiver, SecretRef: meta.LocalObjectReference{ Name: "token", }, @@ -254,12 +255,12 @@ func Test_validate(t *testing.T) { }, { name: "missing secret", - receiver: &v1beta1.Receiver{ + receiver: &apiv1.Receiver{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-secret", }, - Spec: v1beta1.ReceiverSpec{ - Type: v1beta1.GenericReceiver, + Spec: apiv1.ReceiverSpec{ + Type: apiv1.GenericReceiver, SecretRef: meta.LocalObjectReference{ Name: "non-existing", }, @@ -270,7 +271,7 @@ func Test_validate(t *testing.T) { } scheme := runtime.NewScheme() - v1beta1.AddToScheme(scheme) + apiv1.AddToScheme(scheme) corev1.AddToScheme(scheme) for _, tt := range tests { diff --git a/internal/server/receiver_handlers.go b/internal/server/receiver_handlers.go index cbd5a4df0..492bc8e7f 100644 --- a/internal/server/receiver_handlers.go +++ b/internal/server/receiver_handlers.go @@ -39,17 +39,17 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/fluxcd/notification-controller/api/v1beta1" + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" ) func (s *ReceiverServer) handlePayload() func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { ctx := context.Background() - digest := url.PathEscape(strings.TrimLeft(r.RequestURI, "/hook/")) + digest := url.PathEscape(strings.TrimLeft(r.RequestURI, apiv1.ReceiverWebhookPath)) s.logger.Info(fmt.Sprintf("handling request: %s", digest)) - var allReceivers v1beta1.ReceiverList + var allReceivers apiv1.ReceiverList err := s.kubeClient.List(ctx, &allReceivers) if err != nil { s.logger.Error(err, "unable to list receivers") @@ -57,11 +57,11 @@ func (s *ReceiverServer) handlePayload() func(w http.ResponseWriter, r *http.Req return } - receivers := make([]v1beta1.Receiver, 0) + receivers := make([]apiv1.Receiver, 0) for _, receiver := range allReceivers.Items { if !receiver.Spec.Suspend && conditions.IsReady(&receiver) && - receiver.Status.URL == fmt.Sprintf("/hook/%s", digest) { + receiver.Status.WebhookPath == fmt.Sprintf("%s%s", apiv1.ReceiverWebhookPath, digest) { receivers = append(receivers, receiver) } } @@ -74,7 +74,7 @@ func (s *ReceiverServer) handlePayload() func(w http.ResponseWriter, r *http.Req withErrors := false for _, receiver := range receivers { logger := s.logger.WithValues( - "reconciler kind", v1beta1.ReceiverKind, + "reconciler kind", apiv1.ReceiverKind, "name", receiver.Name, "namespace", receiver.Namespace) @@ -104,21 +104,21 @@ func (s *ReceiverServer) handlePayload() func(w http.ResponseWriter, r *http.Req } } -func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver, r *http.Request) error { +func (s *ReceiverServer) validate(ctx context.Context, receiver apiv1.Receiver, r *http.Request) error { token, err := s.token(ctx, receiver) if err != nil { return fmt.Errorf("unable to read token, error: %w", err) } logger := s.logger.WithValues( - "reconciler kind", v1beta1.ReceiverKind, + "reconciler kind", apiv1.ReceiverKind, "name", receiver.Name, "namespace", receiver.Namespace) switch receiver.Spec.Type { - case v1beta1.GenericReceiver: + case apiv1.GenericReceiver: return nil - case v1beta1.GenericHMACReceiver: + case apiv1.GenericHMACReceiver: b, err := io.ReadAll(r.Body) if err != nil { return fmt.Errorf("unable to read request body: %s", err) @@ -129,7 +129,7 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver return fmt.Errorf("unable to validate HMAC signature: %s", err) } return nil - case v1beta1.GitHubReceiver: + case apiv1.GitHubReceiver: _, err := github.ValidatePayload(r, []byte(token)) if err != nil { return fmt.Errorf("the GitHub signature header is invalid, err: %w", err) @@ -151,7 +151,7 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver logger.Info(fmt.Sprintf("handling GitHub event: %s", event)) return nil - case v1beta1.GitLabReceiver: + case apiv1.GitLabReceiver: if r.Header.Get("X-Gitlab-Token") != token { return fmt.Errorf("the X-Gitlab-Token header value does not match the receiver token") } @@ -172,7 +172,7 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver logger.Info(fmt.Sprintf("handling GitLab event: %s", event)) return nil - case v1beta1.BitbucketReceiver: + case apiv1.BitbucketReceiver: _, err := github.ValidatePayload(r, []byte(token)) if err != nil { return fmt.Errorf("the Bitbucket server signature header is invalid, err: %w", err) @@ -194,7 +194,7 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver logger.Info(fmt.Sprintf("handling Bitbucket server event: %s", event)) return nil - case v1beta1.QuayReceiver: + case apiv1.QuayReceiver: type payload struct { DockerUrl string `json:"docker_url"` UpdatedTags []string `json:"updated_tags"` @@ -207,14 +207,14 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver logger.Info(fmt.Sprintf("handling Quay event from %s", p.DockerUrl)) return nil - case v1beta1.HarborReceiver: + case apiv1.HarborReceiver: if r.Header.Get("Authorization") != token { return fmt.Errorf("the Harbor Authorization header value does not match the receiver token") } logger.Info("handling Harbor event") return nil - case v1beta1.DockerHubReceiver: + case apiv1.DockerHubReceiver: type payload struct { PushData struct { Tag string `json:"tag"` @@ -230,11 +230,8 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver logger.Info(fmt.Sprintf("handling DockerHub event from %s for tag %s", p.Repository.URL, p.PushData.Tag)) return nil - case v1beta1.GCRReceiver: - const ( - insert = "insert" - tokenIndex = len("Bearer ") - ) + case apiv1.GCRReceiver: + const tokenIndex = len("Bearer ") type data struct { Action string `json:"action"` @@ -271,7 +268,7 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver logger.Info(fmt.Sprintf("handling GCR event from %s for tag %s", d.Digest, d.Tag)) return nil - case v1beta1.NexusReceiver: + case apiv1.NexusReceiver: signature := r.Header.Get("X-Nexus-Webhook-Signature") if len(signature) == 0 { return fmt.Errorf("Nexus signature is missing from header") @@ -296,7 +293,7 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver logger.Info(fmt.Sprintf("handling Nexus event from %s", p.RepositoryName)) return nil - case v1beta1.ACRReceiver: + case apiv1.ACRReceiver: type target struct { Repository string `json:"repository"` Tag string `json:"tag"` @@ -319,7 +316,7 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver v1beta1.Receiver return fmt.Errorf("recevier type '%s' not supported", receiver.Spec.Type) } -func (s *ReceiverServer) token(ctx context.Context, receiver v1beta1.Receiver) (string, error) { +func (s *ReceiverServer) token(ctx context.Context, receiver apiv1.Receiver) (string, error) { token := "" secretName := types.NamespacedName{ Namespace: receiver.GetNamespace(), @@ -341,7 +338,7 @@ func (s *ReceiverServer) token(ctx context.Context, receiver v1beta1.Receiver) ( return token, nil } -func (s *ReceiverServer) annotate(ctx context.Context, resource v1beta1.CrossNamespaceObjectReference, defaultNamespace string) error { +func (s *ReceiverServer) annotate(ctx context.Context, resource apiv1.CrossNamespaceObjectReference, defaultNamespace string) error { namespace := defaultNamespace if resource.Namespace != "" { namespace = resource.Namespace @@ -355,6 +352,7 @@ func (s *ReceiverServer) annotate(ctx context.Context, resource v1beta1.CrossNam "Bucket": "source.toolkit.fluxcd.io/v1beta2", "HelmRepository": "source.toolkit.fluxcd.io/v1beta2", "GitRepository": "source.toolkit.fluxcd.io/v1beta2", + "OCIRepository": "source.toolkit.fluxcd.io/v1beta2", "ImageRepository": "image.toolkit.fluxcd.io/v1beta1", } diff --git a/internal/server/receiver_server.go b/internal/server/receiver_server.go index 0f601710a..0ea4ccbd9 100644 --- a/internal/server/receiver_server.go +++ b/internal/server/receiver_server.go @@ -18,18 +18,16 @@ package server import ( "context" - "crypto/sha256" - "fmt" "net/http" - "net/url" "os" - "strings" "time" "github.com/go-logr/logr" "github.com/slok/go-http-metrics/middleware" "github.com/slok/go-http-metrics/middleware/std" "sigs.k8s.io/controller-runtime/pkg/client" + + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" ) // ReceiverServer handles webhook POST requests @@ -39,7 +37,7 @@ type ReceiverServer struct { kubeClient client.Client } -// NewEventServer returns an HTTP server that handles webhooks +// NewReceiverServer returns an HTTP server that handles webhooks func NewReceiverServer(port string, logger logr.Logger, kubeClient client.Client) *ReceiverServer { return &ReceiverServer{ port: port, @@ -51,7 +49,7 @@ func NewReceiverServer(port string, logger logr.Logger, kubeClient client.Client // ListenAndServe starts the HTTP server on the specified port func (s *ReceiverServer) ListenAndServe(stopCh <-chan struct{}, mdlw middleware.Middleware) { mux := http.NewServeMux() - mux.Handle("/hook/", http.HandlerFunc(s.handlePayload())) + mux.Handle(apiv1.ReceiverWebhookPath, http.HandlerFunc(s.handlePayload())) h := std.Handler("", mdlw, mux) srv := &http.Server{ Addr: s.port, @@ -76,10 +74,3 @@ func (s *ReceiverServer) ListenAndServe(stopCh <-chan struct{}, mdlw middleware. s.logger.Info("Receiver server stopped") } } - -func receiverKeyFunc(r *http.Request) (string, error) { - id := url.PathEscape(strings.TrimLeft(r.RequestURI, "/hook/")) - val := strings.Join([]string{"receiver", id}, "/") - digest := sha256.Sum256([]byte(val)) - return fmt.Sprintf("%x", digest), nil -} diff --git a/main.go b/main.go index 666309416..85627d7ab 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +Copyright 2022 The Flux authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,13 +21,6 @@ import ( "os" "time" - "github.com/fluxcd/pkg/runtime/acl" - "github.com/fluxcd/pkg/runtime/client" - helper "github.com/fluxcd/pkg/runtime/controller" - "github.com/fluxcd/pkg/runtime/leaderelection" - "github.com/fluxcd/pkg/runtime/logger" - "github.com/fluxcd/pkg/runtime/pprof" - "github.com/fluxcd/pkg/runtime/probes" "github.com/sethvargo/go-limiter/memorystore" prommetrics "github.com/slok/go-http-metrics/metrics/prometheus" "github.com/slok/go-http-metrics/middleware" @@ -38,7 +31,15 @@ import ( ctrl "sigs.k8s.io/controller-runtime" crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" - "github.com/fluxcd/notification-controller/api/v1beta1" + "github.com/fluxcd/pkg/runtime/acl" + "github.com/fluxcd/pkg/runtime/client" + helper "github.com/fluxcd/pkg/runtime/controller" + "github.com/fluxcd/pkg/runtime/leaderelection" + "github.com/fluxcd/pkg/runtime/logger" + "github.com/fluxcd/pkg/runtime/pprof" + "github.com/fluxcd/pkg/runtime/probes" + + apiv1 "github.com/fluxcd/notification-controller/api/v1beta2" "github.com/fluxcd/notification-controller/controllers" "github.com/fluxcd/notification-controller/internal/server" // +kubebuilder:scaffold:imports @@ -54,7 +55,7 @@ var ( func init() { _ = clientgoscheme.AddToScheme(scheme) - _ = v1beta1.AddToScheme(scheme) + _ = apiv1.AddToScheme(scheme) // +kubebuilder:scaffold:scheme } @@ -123,9 +124,10 @@ func main() { metricsH := helper.MustMakeMetrics(mgr) if err = (&controllers.ProviderReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Metrics: metricsH, + Client: mgr.GetClient(), + ControllerName: controllerName, + Metrics: metricsH, + EventRecorder: mgr.GetEventRecorderFor(controllerName), }).SetupWithManagerAndOptions(mgr, controllers.ProviderReconcilerOptions{ MaxConcurrentReconciles: concurrent, RateLimiter: helper.GetRateLimiter(rateLimiterOptions), @@ -134,9 +136,10 @@ func main() { os.Exit(1) } if err = (&controllers.AlertReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Metrics: metricsH, + Client: mgr.GetClient(), + ControllerName: controllerName, + Metrics: metricsH, + EventRecorder: mgr.GetEventRecorderFor(controllerName), }).SetupWithManagerAndOptions(mgr, controllers.AlertReconcilerOptions{ MaxConcurrentReconciles: concurrent, RateLimiter: helper.GetRateLimiter(rateLimiterOptions), @@ -145,9 +148,10 @@ func main() { os.Exit(1) } if err = (&controllers.ReceiverReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Metrics: metricsH, + Client: mgr.GetClient(), + ControllerName: controllerName, + Metrics: metricsH, + EventRecorder: mgr.GetEventRecorderFor(controllerName), }).SetupWithManagerAndOptions(mgr, controllers.ReceiverReconcilerOptions{ MaxConcurrentReconciles: concurrent, RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
+ReconcileRequestStatus
+ + +github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus + + +
+

+(Members of ReconcileRequestStatus are embedded into this type.) +

+
conditions
@@ -1023,6 +1130,7 @@ Defaults to false.

(Optional) +

Conditions holds the conditions for the Receiver.

(Optional) -

Generated webhook URL in the format +

URL is the generated incoming webhook address in the format +of ‘/hook/sha256sum(token+name+namespace)’. +Deprecated: Replaced by WebhookPath.

+
+webhookPath
+ +string + +
+(Optional) +

WebhookPath is the generated incoming webhook address in the format of ‘/hook/sha256sum(token+name+namespace)’.

(Optional) -

ObservedGeneration is the last observed generation.

+

ObservedGeneration is the last observed generation of the Receiver object.