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:
-Package v1beta1 contains API Schema definitions for the notification v1beta1 API group
+Package v1beta2 contains API Schema definitions for the notification v1beta2 API group.
Resource Types: -Alert is the Schema for the alerts API
apiVersion
notification.toolkit.fluxcd.io/v1beta1
+notification.toolkit.fluxcd.io/v1beta2
spec
Send events using this provider.
+ProviderRef specifies which Provider this Alert should use.
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
Filter events based on the involved objects.
+EventSources specifies how to filter events based +on the involved object kind, name and namespace.
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.
Short description of the impact and affected cluster.
+Summary holds a short description of the impact and affected cluster.
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.
status
Provider is the Schema for the providers API
+Provider is the Schema for the providers API.
-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 ( CertSecretRef specifies the Secret containing
+a PEM-encoded CA certificate ( |
|
(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. |
status
Receiver is the Schema for the receivers API
+Receiver is the Schema for the receivers API.
-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. |
status
AlertSpec defines an alerting rule for events involving a list of objects
+AlertSpec defines an alerting rule for events involving a list of objects.
- 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 defines the observed state of Alert
+AlertStatus defines the observed state of the Alert.
+ReconcileRequestStatus + + +github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus + + + |
+
+
+(Members of |
+
conditions @@ -625,6 +672,7 @@ Defaults to false. |
(Optional)
+ Conditions holds the conditions for the Alert. |
(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 @@ stringAPI version of the referent
+API version of the referent.
Kind of the referent
+Kind of the referent.
Name of the referent
+Name of the referent.
Namespace of the referent
+Namespace of the referent.
(Appears on: -Provider) +Provider)
-ProviderSpec defines the desired state of Provider
+ProviderSpec defines the desired state of the Provider.
- 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 ( CertSecretRef specifies the Secret containing
+a PEM-encoded CA certificate ( |
|
(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. |
(Appears on: -Provider) +Provider)
-ProviderStatus defines the observed state of Provider
+ProviderStatus defines the observed state of the Provider.
-observedGeneration + ReconcileRequestStatus -int64 + +github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus + |
-(Optional)
- ObservedGeneration is the last reconciled generation. +
+(Members of |
(Optional)
+ Conditions holds the conditions for the Provider. + |
+|
+observedGeneration + +int64 + + |
+
+(Optional)
+ ObservedGeneration is the last reconciled generation. |
(Appears on: -Receiver) +Receiver)
-ReceiverSpec defines the desired state of Receiver
+ReceiverSpec defines the desired state of the Receiver.
+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. |
(Appears on: -Receiver) +Receiver)
-ReceiverStatus defines the observed state of Receiver
+ReceiverStatus defines the observed state of the Receiver.
+ReconcileRequestStatus + + +github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus + + + |
+
+
+(Members of |
+
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. |
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: