diff --git a/PROJECT b/PROJECT index b830cb5..9469d7b 100644 --- a/PROJECT +++ b/PROJECT @@ -32,4 +32,12 @@ resources: kind: Group path: github.com/checkly/checkly-operator/apis/checkly/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + controller: true + domain: checklyhq.com + group: k8s + kind: AlertChannel + path: github.com/checkly/checkly-operator/apis/checkly/v1alpha1 + version: v1alpha1 version: "3" diff --git a/README.md b/README.md index 3278af9..0b52cd9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # checkly-operator -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=checkly_checkly-operator&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=checkly_checkly-operator) [![Build and push](https://github.com/checkly/checkly-operator/actions/workflows/main-merge.yaml/badge.svg)](https://github.com/checkly/checkly-operator/actions/workflows/main-merge.yaml) [![Code Coverage](https://sonarcloud.io/api/project_badges/measure?project=checkly_checkly-operator&metric=coverage)](https://sonarcloud.io/summary/new_code?id=checkly_checkly-operator) +[![Build and push](https://github.com/checkly/checkly-operator/actions/workflows/main-merge.yaml/badge.svg)](https://github.com/checkly/checkly-operator/actions/workflows/main-merge.yaml) A kubernetes operator for [checklyhq.com](https://checklyhq.com). -The operator can create checklyhq.com checks and groups based of kubernetes CRDs and Ingress object annotations. +The operator can create checklyhq.com checks, groups and alert channels based of kubernetes CRDs and Ingress object annotations. ## Development diff --git a/apis/checkly/v1alpha1/alertchannel_types.go b/apis/checkly/v1alpha1/alertchannel_types.go new file mode 100644 index 0000000..b4f7469 --- /dev/null +++ b/apis/checkly/v1alpha1/alertchannel_types.go @@ -0,0 +1,85 @@ +/* +Copyright 2022. + +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 v1alpha1 + +import ( + "github.com/checkly/checkly-go-sdk" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AlertChannelSpec defines the desired state of AlertChannel +type AlertChannelSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // SendRecovery determines if the Recovery event should be sent to the alert channel + SendRecovery bool `json:"sendrecovery,omitempty"` + + // SendFailure determines if the Failure event should be sent to the alerting channel + SendFailure bool `json:"sendfailure,omitempty"` + + // OpsGenie holds information about the Opsgenie alert configuration + OpsGenie AlertChannelOpsGenie `json:"opsgenie,omitempty"` + + // Email holds information about the Email alert configuration + Email checkly.AlertChannelEmail `json:"email,omitempty"` +} + +type AlertChannelOpsGenie struct { + // APISecret determines where the secret ref is to pull the OpsGenie API key from + APISecret corev1.ObjectReference `json:"apisecret"` + + // Region holds information about the OpsGenie region (EU or US) + Region string `json:"region,omitempty"` + + // Priority assigned to the alerts sent from checklyhq.com + Priority string `json:"priority,omitempty"` +} + +// AlertChannelStatus defines the observed state of AlertChannel +type AlertChannelStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + ID int64 `json:"id"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:resource:scope=Cluster + +// AlertChannel is the Schema for the alertchannels API +type AlertChannel struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AlertChannelSpec `json:"spec,omitempty"` + Status AlertChannelStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// AlertChannelList contains a list of AlertChannel +type AlertChannelList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AlertChannel `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AlertChannel{}, &AlertChannelList{}) +} diff --git a/apis/checkly/v1alpha1/group_types.go b/apis/checkly/v1alpha1/group_types.go index 3855c4f..a2b95af 100644 --- a/apis/checkly/v1alpha1/group_types.go +++ b/apis/checkly/v1alpha1/group_types.go @@ -34,7 +34,7 @@ type GroupSpec struct { // Activated determines if the created group is muted or not, default false Activated bool `json:"muted,omitempty"` - // AlertChannel determines where to send alerts + // AlertChannels determines where to send alerts AlertChannels []string `json:"alertchannel,omitempty"` } diff --git a/apis/checkly/v1alpha1/zz_generated.deepcopy.go b/apis/checkly/v1alpha1/zz_generated.deepcopy.go index ff60d77..19c5ec0 100644 --- a/apis/checkly/v1alpha1/zz_generated.deepcopy.go +++ b/apis/checkly/v1alpha1/zz_generated.deepcopy.go @@ -25,6 +25,113 @@ import ( 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 *AlertChannel) DeepCopyInto(out *AlertChannel) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertChannel. +func (in *AlertChannel) DeepCopy() *AlertChannel { + if in == nil { + return nil + } + out := new(AlertChannel) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AlertChannel) 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 *AlertChannelList) DeepCopyInto(out *AlertChannelList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AlertChannel, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertChannelList. +func (in *AlertChannelList) DeepCopy() *AlertChannelList { + if in == nil { + return nil + } + out := new(AlertChannelList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AlertChannelList) 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 *AlertChannelOpsGenie) DeepCopyInto(out *AlertChannelOpsGenie) { + *out = *in + out.APISecret = in.APISecret +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertChannelOpsGenie. +func (in *AlertChannelOpsGenie) DeepCopy() *AlertChannelOpsGenie { + if in == nil { + return nil + } + out := new(AlertChannelOpsGenie) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AlertChannelSpec) DeepCopyInto(out *AlertChannelSpec) { + *out = *in + out.OpsGenie = in.OpsGenie + out.Email = in.Email +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertChannelSpec. +func (in *AlertChannelSpec) DeepCopy() *AlertChannelSpec { + if in == nil { + return nil + } + out := new(AlertChannelSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AlertChannelStatus) DeepCopyInto(out *AlertChannelStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertChannelStatus. +func (in *AlertChannelStatus) DeepCopy() *AlertChannelStatus { + if in == nil { + return nil + } + out := new(AlertChannelStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApiCheck) DeepCopyInto(out *ApiCheck) { *out = *in diff --git a/config/crd/bases/k8s.checklyhq.com_alertchannels.yaml b/config/crd/bases/k8s.checklyhq.com_alertchannels.yaml new file mode 100644 index 0000000..d33a9a7 --- /dev/null +++ b/config/crd/bases/k8s.checklyhq.com_alertchannels.yaml @@ -0,0 +1,121 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.0 + creationTimestamp: null + name: alertchannels.k8s.checklyhq.com +spec: + group: k8s.checklyhq.com + names: + kind: AlertChannel + listKind: AlertChannelList + plural: alertchannels + singular: alertchannel + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: AlertChannel is the Schema for the alertchannels 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: AlertChannelSpec defines the desired state of AlertChannel + properties: + email: + description: Email holds information about the Email alert configuration + properties: + address: + type: string + required: + - address + type: object + opsgenie: + description: OpsGenie holds information about the Opsgenie alert configuration + properties: + apisecret: + description: APISecret determines where the secret ref is to pull + the OpsGenie API key from + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead + of an entire object, this string should contain a valid + JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part + of an object. TODO: this design is not final and this field + is subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + priority: + description: Priority assigned to the alerts sent from checklyhq.com + type: string + region: + description: Region holds information about the OpsGenie region + (EU or US) + type: string + required: + - apisecret + type: object + sendfailure: + description: SendFailure determines if the Failure event should be + sent to the alerting channel + type: boolean + sendrecovery: + description: SendRecovery determines if the Recovery event should + be sent to the alert channel + type: boolean + type: object + status: + description: AlertChannelStatus defines the observed state of AlertChannel + properties: + id: + description: 'INSERT ADDITIONAL STATUS FIELD - define observed state + of cluster Important: Run "make" to regenerate code after modifying + this file' + format: int64 + type: integer + required: + - id + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/k8s.checklyhq.com_groups.yaml b/config/crd/bases/k8s.checklyhq.com_groups.yaml index a098b9b..16e2519 100644 --- a/config/crd/bases/k8s.checklyhq.com_groups.yaml +++ b/config/crd/bases/k8s.checklyhq.com_groups.yaml @@ -36,7 +36,7 @@ spec: description: GroupSpec defines the desired state of Group properties: alertchannel: - description: AlertChannel determines where to send alerts + description: AlertChannels determines where to send alerts items: type: string type: array diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index daf41c8..33744a5 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,7 @@ resources: # - bases/check.checklyhq.com_apis.yaml - bases/k8s.checklyhq.com_apichecks.yaml - bases/k8s.checklyhq.com_groups.yaml +- bases/k8s.checklyhq.com_alertchannels.yaml #+kubebuilder:scaffold:crdkustomizeresource # patchesStrategicMerge: @@ -13,6 +14,7 @@ resources: #- patches/webhook_in_apis.yaml #- patches/webhook_in_apichecks.yaml #- patches/webhook_in_groups.yaml +#- patches/webhook_in_alertchannels.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -20,6 +22,7 @@ resources: #- patches/cainjection_in_apis.yaml #- patches/cainjection_in_apichecks.yaml #- patches/cainjection_in_groups.yaml +#- patches/cainjection_in_alertchannels.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_checkly_alertchannels.yaml b/config/crd/patches/cainjection_in_checkly_alertchannels.yaml new file mode 100644 index 0000000..6708d58 --- /dev/null +++ b/config/crd/patches/cainjection_in_checkly_alertchannels.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: alertchannels.checkly.checklyhq.com diff --git a/config/crd/patches/webhook_in_checkly_alertchannels.yaml b/config/crd/patches/webhook_in_checkly_alertchannels.yaml new file mode 100644 index 0000000..54eea09 --- /dev/null +++ b/config/crd/patches/webhook_in_checkly_alertchannels.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: alertchannels.checkly.checklyhq.com +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/checkly_alertchannel_editor_role.yaml b/config/rbac/checkly_alertchannel_editor_role.yaml new file mode 100644 index 0000000..69e8bca --- /dev/null +++ b/config/rbac/checkly_alertchannel_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit alertchannels. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: alertchannel-editor-role +rules: +- apiGroups: + - checkly.checklyhq.com + resources: + - alertchannels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - checkly.checklyhq.com + resources: + - alertchannels/status + verbs: + - get diff --git a/config/rbac/checkly_alertchannel_viewer_role.yaml b/config/rbac/checkly_alertchannel_viewer_role.yaml new file mode 100644 index 0000000..5e34427 --- /dev/null +++ b/config/rbac/checkly_alertchannel_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view alertchannels. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: alertchannel-viewer-role +rules: +- apiGroups: + - checkly.checklyhq.com + resources: + - alertchannels + verbs: + - get + - list + - watch +- apiGroups: + - checkly.checklyhq.com + resources: + - alertchannels/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e6dac37..aa45aaa 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -5,6 +5,37 @@ metadata: creationTimestamp: null name: manager-role rules: +- resources: + - secrets + verbs: + - get + - list +- apiGroups: + - k8s.checklyhq.com + resources: + - alertchannels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - k8s.checklyhq.com + resources: + - alertchannels/finalizers + verbs: + - update +- apiGroups: + - k8s.checklyhq.com + resources: + - alertchannels/status + verbs: + - get + - patch + - update - apiGroups: - k8s.checklyhq.com resources: diff --git a/config/samples/checkly_v1alpha1_alertchannel.yaml b/config/samples/checkly_v1alpha1_alertchannel.yaml new file mode 100644 index 0000000..921a447 --- /dev/null +++ b/config/samples/checkly_v1alpha1_alertchannel.yaml @@ -0,0 +1,15 @@ +apiVersion: k8s.checklyhq.com/v1alpha1 +kind: AlertChannel +metadata: + name: alertchannel-sample +spec: +# only one of the bellow can be specified at once, either email or opsgenie + email: + address: "foo@bar.baz" + # opsgenie: + # apikey: + # name: test-secret # Name of the secret which holds the API key + # namespace: default # Namespace of the secret + # fieldPath: "TEST" # Key inside the secret + # priority: "P3" + # region: "US" diff --git a/config/samples/checkly_v1alpha1_group.yaml b/config/samples/checkly_v1alpha1_group.yaml index 878137d..89ddd01 100644 --- a/config/samples/checkly_v1alpha1_group.yaml +++ b/config/samples/checkly_v1alpha1_group.yaml @@ -8,3 +8,5 @@ spec: locations: - eu-west-1 - eu-west-2 + alertchannel: + - alertchannel-sample diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 72294a4..cc518b3 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -3,4 +3,5 @@ resources: - networking_v1_ingress.yaml - checkly_v1alpha1_apicheck.yaml - checkly_v1alpha1_group.yaml +- checkly_v1alpha1_alertchannel.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/controllers/checkly/alertchannel_controller.go b/controllers/checkly/alertchannel_controller.go new file mode 100644 index 0000000..423f1e4 --- /dev/null +++ b/controllers/checkly/alertchannel_controller.go @@ -0,0 +1,191 @@ +/* +Copyright 2022. + +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 checkly + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/checkly/checkly-go-sdk" + checklyv1alpha1 "github.com/checkly/checkly-operator/apis/checkly/v1alpha1" + external "github.com/checkly/checkly-operator/external/checkly" +) + +// AlertChannelReconciler reconciles a AlertChannel object +type AlertChannelReconciler struct { + client.Client + Scheme *runtime.Scheme + ApiClient checkly.Client +} + +//+kubebuilder:rbac:groups=checkly.checklyhq.com,resources=alertchannels,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=checkly.checklyhq.com,resources=alertchannels/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=checkly.checklyhq.com,resources=alertchannels/finalizers,verbs=update +//+kubebuilder:rbac:groups=,resources=secrets,verbs=get;list + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.1/pkg/reconcile +func (r *AlertChannelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + logger.Info("Reconciler started") + + acFinalizer := "k8s.checklyhq.com/finalizer" + + ac := &checklyv1alpha1.AlertChannel{} + + err := r.Get(ctx, req.NamespacedName, ac) + + // //////////////////////////////// + // Delete Logic + // /////////////////////////////// + if err != nil { + if errors.IsNotFound(err) { + // The resource has been deleted + logger.Info("Deleted", "checkly AlertChannel ID", ac.Status.ID) + return ctrl.Result{}, nil + } + // Error reading the object + logger.Error(err, "can't read the object") + return ctrl.Result{}, nil + } + + // //////////////////////////////// + // Remove Finalizer Logic + // /////////////////////////////// + + if ac.GetDeletionTimestamp() != nil { + if controllerutil.ContainsFinalizer(ac, acFinalizer) { + logger.Info("Finalizer is present, trying to delete Checkly AlertChannel", "ID", ac.Status.ID) + err := external.DeleteAlertChannel(ac, r.ApiClient) + if err != nil { + logger.Error(err, "Failed to delete checkly AlertChannel") + return ctrl.Result{}, err + } + + logger.Info("Successfully deleted checkly AlertChannel", "ID", ac.Status.ID) + + controllerutil.RemoveFinalizer(ac, acFinalizer) + err = r.Update(ctx, ac) + if err != nil { + return ctrl.Result{}, err + } + logger.Info("Successfully deleted finalizer from AlertChannel") + } + return ctrl.Result{}, nil + } + + // ///////////////////////////// + // Add Finalizer logic + // //////////////////////////// + if !controllerutil.ContainsFinalizer(ac, acFinalizer) { + controllerutil.AddFinalizer(ac, acFinalizer) + err = r.Update(ctx, ac) + if err != nil { + logger.Error(err, "Failed to update AlertChannel status") + return ctrl.Result{}, err + } + logger.Info("Added finalizer", "checkly AlertChannel ID", ac.Status.ID) + return ctrl.Result{}, nil + } + + // ///////////////////////////// + // OpsGenie logic + secret retrieval + // //////////////////////////// + opsGenieConfig := checkly.AlertChannelOpsgenie{} + if ac.Spec.OpsGenie.APISecret != (corev1.ObjectReference{}) { + secret := &corev1.Secret{} + err := r.Get(ctx, + types.NamespacedName{ + Name: ac.Spec.OpsGenie.APISecret.Name, + Namespace: ac.Spec.OpsGenie.APISecret.Namespace}, + secret) + if err != nil { + logger.Info("Unable to read secret for API Key", "err", err) + return ctrl.Result{}, err + } + + secretValue := string(secret.Data[ac.Spec.OpsGenie.APISecret.FieldPath]) + if secretValue == "" { + logger.Info("Secret value is empty") + return ctrl.Result{}, err + } + + opsGenieConfig = checkly.AlertChannelOpsgenie{ + Name: ac.Name, + APIKey: secretValue, + Region: ac.Spec.OpsGenie.Region, + Priority: ac.Spec.OpsGenie.Priority, + } + + } + + // ///////////////////////////// + // Update logic + // //////////////////////////// + + // Determine if it's a new object or if it's an update to an existing object + if ac.Status.ID != 0 { + // Existing object, we need to update it + logger.Info("Existing object, with ID", "checkly AlertChannel ID", ac.Status.ID) + err := external.UpdateAlertChannel(ac, opsGenieConfig, r.ApiClient) + if err != nil { + logger.Error(err, "Failed to update checkly AlertChannel") + return ctrl.Result{}, err + } + logger.Info("Updated checkly AlertChannel", "ID", ac.Status.ID) + return ctrl.Result{}, nil + } + + // ///////////////////////////// + // Create logic + // //////////////////////////// + acID, err := external.CreateAlertChannel(ac, opsGenieConfig, r.ApiClient) + if err != nil { + logger.Error(err, "Failed to create checkly AlertChannel") + return ctrl.Result{}, err + } + + // Update the custom resource Status with the returned ID + ac.Status.ID = acID + err = r.Status().Update(ctx, ac) + if err != nil { + logger.Error(err, "Failed to update AlertChannel status", "ID", ac.Status.ID) + return ctrl.Result{}, err + } + logger.Info("New checkly AlertChannel created", "ID", ac.Status.ID) + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *AlertChannelReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&checklyv1alpha1.AlertChannel{}). + Complete(r) +} diff --git a/controllers/checkly/alertchannel_controller_test.go b/controllers/checkly/alertchannel_controller_test.go new file mode 100644 index 0000000..563bea0 --- /dev/null +++ b/controllers/checkly/alertchannel_controller_test.go @@ -0,0 +1,195 @@ +/* +Copyright 2022. + +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. +*/ + +// Example code used for influence: https://github.com/Azure/azure-databricks-operator/blob/0f722a710fea06b86ecdccd9455336ca712bf775/controllers/dcluster_controller_test.go + +package checkly + +import ( + "context" + "time" + + "github.com/checkly/checkly-go-sdk" + checklyv1alpha1 "github.com/checkly/checkly-operator/apis/checkly/v1alpha1" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("ApiCheck Controller", func() { + + // Define utility constants for object names and testing timeouts/durations and intervals. + const ( + timeout = time.Second * 10 + duration = time.Second * 10 + interval = time.Millisecond * 250 + ) + + BeforeEach(func() { + // Add any setup steps that needs to be executed before each test + }) + + AfterEach(func() { + // Add any teardown steps that needs to be executed after each test + acKey := types.NamespacedName{ + Name: "test-alert-channel", + } + f := &checklyv1alpha1.AlertChannel{} + k8sClient.Get(context.Background(), acKey, f) + k8sClient.Delete(context.Background(), f) + }) + + // Add Tests for OpenAPI validation (or additonal CRD features) specified in + // your API definition. + // Avoid adding tests for vanilla CRUD operations because they would + // test Kubernetes API server, which isn't the goal here. + Context("AlertChannels", func() { + It("Full reconciliation", func() { + + acKey := types.NamespacedName{ + Name: "test-alert-channel", + } + + secretKey := types.NamespacedName{ + Name: "test-secret", + Namespace: "default", + } + + secretData := map[string][]byte{ + "TEST": []byte("test"), + } + + alertChannel := &checklyv1alpha1.AlertChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: acKey.Name, + }, + Spec: checklyv1alpha1.AlertChannelSpec{ + SendFailure: false, + Email: checkly.AlertChannelEmail{ + Address: "foo@bar.baz", + }, + }, + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretKey.Name, + Namespace: secretKey.Namespace, + }, + Data: secretData, + } + + // Create + Expect(k8sClient.Create(context.Background(), alertChannel)).Should(Succeed()) + Expect(k8sClient.Create(context.Background(), secret)).Should(Succeed()) + + // Status.ID should be present + By("Expecting AlertChannel ID") + Eventually(func() bool { + f := &checklyv1alpha1.AlertChannel{} + err := k8sClient.Get(context.Background(), acKey, f) + if f.Status.ID == 3 && err == nil { + return true + } else { + return false + } + }, timeout, interval).Should(BeTrue()) + + // Finalizer should be present + By("Expecting finalizer") + Eventually(func() bool { + f := &checklyv1alpha1.AlertChannel{} + err := k8sClient.Get(context.Background(), acKey, f) + if len(f.Finalizers) == 1 && err == nil { + return true + } else { + return false + } + }, timeout, interval).Should(BeTrue()) + + // Update + By("Expecting field update") + Eventually(func() bool { + f := &checklyv1alpha1.AlertChannel{} + err := k8sClient.Get(context.Background(), acKey, f) + if err != nil { + return false + } + + f.Spec.Email = checkly.AlertChannelEmail{} + f.Spec.SendFailure = true + f.Spec.OpsGenie = checklyv1alpha1.AlertChannelOpsGenie{ + APISecret: corev1.ObjectReference{ + Namespace: secretKey.Namespace, + Name: secretKey.Name, + FieldPath: "TEST", + }, + Priority: "999", + Region: "US", + } + err = k8sClient.Update(context.Background(), f) + if err != nil { + return false + } + + u := &checklyv1alpha1.AlertChannel{} + err = k8sClient.Get(context.Background(), acKey, u) + if err != nil { + return false + } + + if u.Spec.SendFailure != true { + return false + } + + if u.Spec.Email != (checkly.AlertChannelEmail{}) { + return false + } + + if u.Spec.OpsGenie.Priority != "999" { + return false + } + + return true + }, timeout, interval).Should(BeTrue()) + + // Delete AlertChannel + By("Expecting to delete alertchannel successfully") + Eventually(func() error { + f := &checklyv1alpha1.AlertChannel{} + k8sClient.Get(context.Background(), acKey, f) + return k8sClient.Delete(context.Background(), f) + }, timeout, interval).Should(Succeed()) + + By("Expecting delete to finish") + Eventually(func() error { + f := &checklyv1alpha1.AlertChannel{} + return k8sClient.Get(context.Background(), acKey, f) + }, timeout, interval).ShouldNot(Succeed()) + + // Delete secret + By("Expecting to delete secret successfully") + Eventually(func() error { + f := &corev1.Secret{} + k8sClient.Get(context.Background(), secretKey, f) + return k8sClient.Delete(context.Background(), f) + }, timeout, interval).Should(Succeed()) + }) + // return + }) +}) diff --git a/controllers/checkly/group_controller.go b/controllers/checkly/group_controller.go index da95f2f..17f06f8 100644 --- a/controllers/checkly/group_controller.go +++ b/controllers/checkly/group_controller.go @@ -21,6 +21,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -111,12 +112,36 @@ func (r *GroupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl return ctrl.Result{}, nil } + // ///////////////////////////// + // AlertChannelsSubscription logic + // //////////////////////////// + var alertChannels []checkly.AlertChannelSubscription + + if len(group.Spec.AlertChannels) != 0 { + for _, alertChannel := range group.Spec.AlertChannels { + ac := &checklyv1alpha1.AlertChannel{} + err := r.Get(ctx, types.NamespacedName{Name: alertChannel}, ac) + if err != nil { + logger.Info("Could not find alertChannel resource", "name", alertChannel) + return ctrl.Result{}, err + } + if ac.Status.ID == 0 { + logger.Info("AlertChannel ID not yet populated, we'll retry") + return ctrl.Result{Requeue: true}, nil + } + alertChannels = append(alertChannels, checkly.AlertChannelSubscription{ + ChannelID: ac.Status.ID, + Activated: true, + }) + } + } + // Create internal Check type internalCheck := external.Group{ Name: group.Name, Activated: group.Spec.Activated, Locations: group.Spec.Locations, - AlertChannels: group.Spec.AlertChannels, + AlertChannels: alertChannels, ID: group.Status.ID, Labels: group.Labels, } diff --git a/controllers/checkly/group_controller_test.go b/controllers/checkly/group_controller_test.go index 07e7383..75a934b 100644 --- a/controllers/checkly/group_controller_test.go +++ b/controllers/checkly/group_controller_test.go @@ -19,6 +19,7 @@ import ( "context" "time" + "github.com/checkly/checkly-go-sdk" checklyv1alpha1 "github.com/checkly/checkly-operator/apis/checkly/v1alpha1" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -54,16 +55,33 @@ var _ = Describe("ApiCheck Controller", func() { Name: "test-group", } + alertChannelKey := types.NamespacedName{ + Name: "test-alertchannel", + } + group := &checklyv1alpha1.Group{ ObjectMeta: metav1.ObjectMeta{ Name: groupKey.Name, }, Spec: checklyv1alpha1.GroupSpec{ - Locations: []string{"eu-west-1"}, + Locations: []string{"eu-west-1"}, + AlertChannels: []string{alertChannelKey.Name}, + }, + } + + alertChannel := &checklyv1alpha1.AlertChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: alertChannelKey.Name, + }, + Spec: checklyv1alpha1.AlertChannelSpec{ + Email: checkly.AlertChannelEmail{ + Address: "foo@bar.baz", + }, }, } // Create + Expect(k8sClient.Create(context.Background(), alertChannel)).Should(Succeed()) Expect(k8sClient.Create(context.Background(), group)).Should(Succeed()) By("Expecting submitted") @@ -118,7 +136,7 @@ var _ = Describe("ApiCheck Controller", func() { } }, timeout, interval).Should(BeTrue()) - // Delete + // Delete group By("Expecting to delete successfully") Eventually(func() error { f := &checklyv1alpha1.Group{} @@ -126,6 +144,14 @@ var _ = Describe("ApiCheck Controller", func() { return k8sClient.Delete(context.Background(), f) }, timeout, interval).Should(Succeed()) + // Delete alertchannel + By("Expecting to delete successfully") + Eventually(func() error { + f := &checklyv1alpha1.AlertChannel{} + k8sClient.Get(context.Background(), alertChannelKey, f) + return k8sClient.Delete(context.Background(), f) + }, timeout, interval).Should(Succeed()) + By("Expecting delete to finish") Eventually(func() error { f := &checklyv1alpha1.Group{} diff --git a/controllers/checkly/suite_test.go b/controllers/checkly/suite_test.go index 52dee41..e611b59 100644 --- a/controllers/checkly/suite_test.go +++ b/controllers/checkly/suite_test.go @@ -143,6 +143,31 @@ var _ = BeforeSuite(func() { } return }) + http.HandleFunc("/v1/alert-channels", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + resp := make(map[string]interface{}) + resp["id"] = 3 + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + return + }) + http.HandleFunc("/v1/alert-channels/3", func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + method := r.Method + switch method { + case "PUT": + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + resp := make(map[string]interface{}) + resp["id"] = 3 + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + case "DELETE": + w.WriteHeader(http.StatusNoContent) + } + return + }) http.ListenAndServe(":5555", nil) }() @@ -160,6 +185,13 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&AlertChannelReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + ApiClient: testClient, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctrl.SetupSignalHandler()) diff --git a/external/checkly/alertChannel.go b/external/checkly/alertChannel.go new file mode 100644 index 0000000..58917a9 --- /dev/null +++ b/external/checkly/alertChannel.go @@ -0,0 +1,83 @@ +package external + +import ( + "context" + "time" + + "github.com/checkly/checkly-go-sdk" + checklyv1alpha1 "github.com/checkly/checkly-operator/apis/checkly/v1alpha1" +) + +func checklyAlertChannel(alertChannel *checklyv1alpha1.AlertChannel, opsGenieConfig checkly.AlertChannelOpsgenie) (ac checkly.AlertChannel, err error) { + sslExpiry := false + + ac = checkly.AlertChannel{ + SendRecovery: &alertChannel.Spec.SendRecovery, + SendFailure: &alertChannel.Spec.SendFailure, + SSLExpiry: &sslExpiry, + } + + if opsGenieConfig != (checkly.AlertChannelOpsgenie{}) { + ac.Type = "OPSGENIE" // Type has to be all caps, see https://developers.checklyhq.com/reference/postv1alertchannels + ac.Opsgenie = &opsGenieConfig + return + } + + if alertChannel.Spec.Email != (checkly.AlertChannelEmail{}) { + ac.Type = "EMAIL" // Type has to be all caps, see https://developers.checklyhq.com/reference/postv1alertchannels + ac.Email = &checkly.AlertChannelEmail{ + Address: alertChannel.Spec.Email.Address, + } + return + } + return +} + +func CreateAlertChannel(alertChannel *checklyv1alpha1.AlertChannel, opsGenieConfig checkly.AlertChannelOpsgenie, client checkly.Client) (ID int64, err error) { + + ac, err := checklyAlertChannel(alertChannel, opsGenieConfig) + if err != nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + gotAlertChannel, err := client.CreateAlertChannel(ctx, ac) + if err != nil { + return + } + + ID = gotAlertChannel.ID + + return +} + +func UpdateAlertChannel(alertChannel *checklyv1alpha1.AlertChannel, opsGenieConfig checkly.AlertChannelOpsgenie, client checkly.Client) (err error) { + ac, err := checklyAlertChannel(alertChannel, opsGenieConfig) + if err != nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, err = client.UpdateAlertChannel(ctx, alertChannel.Status.ID, ac) + if err != nil { + return + } + + return +} + +func DeleteAlertChannel(alertChannel *checklyv1alpha1.AlertChannel, client checkly.Client) (err error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + err = client.DeleteAlertChannel(ctx, alertChannel.Status.ID) + if err != nil { + return + } + + return +} diff --git a/external/checkly/alertChannel_test.go b/external/checkly/alertChannel_test.go new file mode 100644 index 0000000..b4d2ead --- /dev/null +++ b/external/checkly/alertChannel_test.go @@ -0,0 +1,182 @@ +package external + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + "testing" + "time" + + "github.com/checkly/checkly-go-sdk" + checklyv1alpha1 "github.com/checkly/checkly-operator/apis/checkly/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestChecklyAlertChannel(t *testing.T) { + acName := "foo" + acEmailAddress := "foo@bar.baz" + + dataEmpty := checklyv1alpha1.AlertChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: acName, + }, + Spec: checklyv1alpha1.AlertChannelSpec{ + SendRecovery: false, + }, + } + + opsGenieConfigEmpty := checkly.AlertChannelOpsgenie{} + + returned, err := checklyAlertChannel(&dataEmpty, opsGenieConfigEmpty) + if err != nil { + t.Errorf("Expected no error, got %e", err) + } + + if returned.Opsgenie != nil { + t.Errorf("Expected empty Opsgenie config, got %s", returned.Opsgenie) + } + + dataEmail := dataEmpty + dataEmail.Spec.Email = checkly.AlertChannelEmail{ + Address: acEmailAddress, + } + + returned, err = checklyAlertChannel(&dataEmail, opsGenieConfigEmpty) + if err != nil { + t.Errorf("Expected no error, got %e", err) + } + + if returned.Email.Address != acEmailAddress { + t.Errorf("Expected %s, got %s", acEmailAddress, returned.Email.Address) + } + + dataOpsGenieFull := checkly.AlertChannelOpsgenie{ + APIKey: "foo-bar", + Region: "US", + Priority: "999", + Name: "baz", + } + + returned, err = checklyAlertChannel(&dataEmpty, dataOpsGenieFull) + if err != nil { + t.Errorf("Expected no error, got %e", err) + } + + if returned.Opsgenie == nil { + t.Error("Expected Opsgenie field to tbe populated, it's empty") + } + + if returned.Opsgenie.Priority != "999" { + t.Errorf("Expected %s, got %s", "999", returned.Opsgenie.Priority) + } + + if returned.Opsgenie.Region != "US" { + t.Errorf("Expected %s, got %s", "US", returned.Opsgenie.Region) + } + + if returned.Email != nil { + t.Errorf("Expected nil, got %s", returned.Email) + } + +} + +func TestAlertChannelActions(t *testing.T) { + // Generate a different number each time + rand.Seed(time.Now().UnixNano()) + expectedAlertChannelID := rand.Intn(100) + + acName := "foo" + + testData := &checklyv1alpha1.AlertChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: acName, + }, + Spec: checklyv1alpha1.AlertChannelSpec{ + SendRecovery: false, + }, + Status: checklyv1alpha1.AlertChannelStatus{ + ID: int64(expectedAlertChannelID), + }, + } + + opsGenieConfigEmpty := checkly.AlertChannelOpsgenie{} + + // Test errors + testClient := checkly.NewClient( + "http://localhost:5557", + "foobarbaz", + nil, + nil, + ) + testClient.SetAccountId("1234567890") + + // Create fail + _, err := CreateAlertChannel(testData, opsGenieConfigEmpty, testClient) + if err == nil { + t.Error("Expected error, got none") + } + + // Update fail + err = UpdateAlertChannel(testData, opsGenieConfigEmpty, testClient) + if err == nil { + t.Error("Expected error, got none") + } + + // Delete fail + err = DeleteAlertChannel(testData, testClient) + if err == nil { + t.Error("Expected error, got none") + } + + go func() { + http.HandleFunc("/v1/alert-channels", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + resp := make(map[string]interface{}) + resp["id"] = expectedAlertChannelID + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + return + }) + http.HandleFunc(fmt.Sprintf("/v1/alert-channels/%d", expectedAlertChannelID), func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + method := r.Method + switch method { + case "PUT": + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + resp := make(map[string]interface{}) + resp["id"] = expectedAlertChannelID + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + case "DELETE": + w.WriteHeader(http.StatusNoContent) + } + return + }) + http.ListenAndServe(":5557", nil) + }() + + // Create success + testID, err := CreateAlertChannel(testData, opsGenieConfigEmpty, testClient) + if err != nil { + t.Errorf("Expected no error, got %e", err) + } + if testID != int64(expectedAlertChannelID) { + t.Errorf("Expected %d, got %d", testID, int64(expectedAlertChannelID)) + } + + // Update success + err = UpdateAlertChannel(testData, opsGenieConfigEmpty, testClient) + if err != nil { + t.Errorf("Expected no error, got %e", err) + } + + // Delete success + err = DeleteAlertChannel(testData, testClient) + if err != nil { + t.Errorf("Expecte no error, got %e", err) + } + +} diff --git a/external/checkly/group.go b/external/checkly/group.go index d0930a1..1608b1c 100644 --- a/external/checkly/group.go +++ b/external/checkly/group.go @@ -28,7 +28,7 @@ type Group struct { ID int64 Locations []string Activated bool - AlertChannels []string + AlertChannels []checkly.AlertChannelSubscription Labels map[string]string } @@ -66,7 +66,7 @@ func checklyGroup(group Group) (check checkly.Group) { Tags: tags, AlertSettings: alertSettings, UseGlobalAlertSettings: false, - AlertChannelSubscriptions: []checkly.AlertChannelSubscription{}, + AlertChannelSubscriptions: group.AlertChannels, } return diff --git a/main.go b/main.go index b3908c8..c2b8ee6 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" "github.com/checkly/checkly-go-sdk" + checklyv1alpha1 "github.com/checkly/checkly-operator/apis/checkly/v1alpha1" checklycontrollers "github.com/checkly/checkly-operator/controllers/checkly" networkingcontrollers "github.com/checkly/checkly-operator/controllers/networking" @@ -126,6 +127,14 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Group") os.Exit(1) } + if err = (&checklycontrollers.AlertChannelReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ApiClient: client, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "AlertChannel") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {