diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 3c9537e..4837d16 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -95,7 +95,41 @@ jobs: uses: aquasecurity/trivy-action@0.28.0 env: IMAGE: ${{ steps.docker_meta.outputs.tags }} + TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db with: image-ref: ${{ env.IMAGE }} format: "table" exit-code: "1" + + semantic-validate: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + id: lint_pr_title + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: marocchino/sticky-pull-request-comment@v2 + # When the previous steps fails, the workflow would stop. By adding this + # condition you can continue the execution with the populated error message. + if: always() && (steps.lint_pr_title.outputs.error_message != null) + with: + header: pr-title-lint-error + message: | + Hey there and thank you for opening this pull request! 👋🏼 + + We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. + + Details: + + ``` + ${{ steps.lint_pr_title.outputs.error_message }} + ``` + + # Delete a previous comment when the issue has been resolved + - if: ${{ steps.lint_pr_title.outputs.error_message == null }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-title-lint-error + delete: true diff --git a/config/samples/network_v1_ingress.yaml b/config/samples/network_v1_ingress.yaml index 7446ca5..040755a 100644 --- a/config/samples/network_v1_ingress.yaml +++ b/config/samples/network_v1_ingress.yaml @@ -5,17 +5,41 @@ metadata: name: ingress-sample annotations: k8s.checklyhq.com/enabled: "true" - k8s.checklyhq.com/path: "/baz" - # k8s.checklyhq.com/endpoint: "foo.baaz" - Default read from spec.rules[0].host - # k8s.checklyhq.com/success: "200" - Default "200" + # k8s.checklyhq.com/endpoint: "foo.baaz" - Default read from spec.rules[*].host k8s.checklyhq.com/group: "group-sample" # k8s.checklyhq.com/muted: "false" # If not set, default "true" + # k8s.checklyhq.com/path: "/baz" - Default read from spec.rules[*].http.paths[*].path + # k8s.checklyhq.com/success: "200" - Default "200" spec: rules: - host: "foo.bar" http: paths: - - path: / + - path: /foo + pathType: ImplementationSpecific + backend: + service: + name: test-service + port: + number: 8080 + - path: /bar + pathType: ImplementationSpecific + backend: + service: + name: test-service + port: + number: 8080 + - host: "example.com" + http: + paths: + - path: /tea + pathType: ImplementationSpecific + backend: + service: + name: test-service + port: + number: 8080 + - path: /coffee pathType: ImplementationSpecific backend: service: diff --git a/docs/ingress.md b/docs/ingress.md index 8d91fd2..1485857 100644 --- a/docs/ingress.md +++ b/docs/ingress.md @@ -1,11 +1,17 @@ # ingress -We also support kubernetes native `ingress` resources. See [official docs](https://kubernetes.io/docs/concepts/services-networking/ingress/) for more details on what they are and what they do. +Support for kubernetes native `ingress` resources. See [official docs](https://kubernetes.io/docs/concepts/services-networking/ingress/) for more details on what they are and what they do. -We pull out information with the use of `annotations`. The information from the annotations is used to create `ApiCheck` resources, we make use of [ownerReferences](https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/) to link ingress resources to ApiCheck resources. +We pull out information with the use of `annotations` and use the built in spec. The information from the annotations is used to create `ApiCheck` resources, we make use of [ownerReferences](https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/) to link ingress resources to ApiCheck resources. > ***Warning*** -> We currently only support one API check / ingress resource. +> We currently only support API checks for ingress resources. + +## Logic of discovery + +We iterate over the ingress resource's specifications to work out what needs to be created. The operator creates one ApiCheck resource for each `host` + `path`, if in your ingress resource you have 2 hosts with 3 paths each, you'll end up with 6 ApiChecks created. + +Specific annotations are optional, as we can't automatically discover the group you want the Checkly APIChecks to be deployd in. ## Configuration options @@ -14,10 +20,10 @@ The name of the API Check derives from the `metadata.name` of the `ingress` reso | Annotation | Details | Default | |--------------------|-------------|---------| | `k8s.checklyhq.com/enabled` | Bool; Should the operator read the annotations or not | `false` (*required) | -| `k8s.checklyhq.com/path` | String; The URI to put after the `endpoint`, for example `/path` | "" (*required) | -| `k8s.checklyhq.com/endpoint` | String; The host of the URL, for example `/` | Value of `spec.rules[0].Host`, defaults to `https://` (*required) | +| `k8s.checklyhq.com/endpoint` | String; The host of the URL, for example `/` | Value of `spec.rules[0].Host`, defaults to `https://` | | `k8s.checklyhq.com/group` | String; Name of the group to which the check belongs; Kubernetes `Group` resource name` | none (*required)| | `k8s.checklyhq.com/muted` | String; Is the check muted or not | `true` | +| `k8s.checklyhq.com/path` | String; The URI to put after the `endpoint`, for example `/path` | ""| | `k8s.checklyhq.com/success` | String; The expected success code | `200` | ### Example @@ -29,7 +35,7 @@ metadata: name: checkly-operator-ingress annotations: k8s.checklyhq.com/enabled: "true" - k8s.checklyhq.com/path: "/baz" + # k8s.checklyhq.com/path: "/baz" - Default read from spec.rules[0].http.paths[*].path # k8s.checklyhq.com/endpoint: "foo.baaz" - Default read from spec.rules[0].host # k8s.checklyhq.com/success: "200" - Default "200" k8s.checklyhq.com/group: "group-sample" @@ -39,7 +45,14 @@ spec: - host: "foo.bar" http: paths: - - path: / + - path: /foo + pathType: ImplementationSpecific + backend: + service: + name: test-service + port: + number: 8080 + - path: /bar pathType: ImplementationSpecific backend: service: diff --git a/internal/controller/networking/ingress_controller.go b/internal/controller/networking/ingress_controller.go index df500c0..d828e8a 100644 --- a/internal/controller/networking/ingress_controller.go +++ b/internal/controller/networking/ingress_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2022. +Copyright 2024. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package networking import ( "context" "fmt" + "strings" checklyv1alpha1 "github.com/checkly/checkly-operator/api/checkly/v1alpha1" networkingv1 "k8s.io/api/networking/v1" @@ -27,6 +28,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" 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" ) @@ -52,81 +54,119 @@ func (r *IngressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct logger := log.FromContext(ctx) logger.Info("Reconciler started") - ingress := &networkingv1.Ingress{} - apiCheck := &checklyv1alpha1.ApiCheck{} - + // //////////////////////////////// + // Setup + // /////////////////////////////// + ingress := networkingv1.Ingress{} + // apiCheck := &checklyv1alpha1.ApiCheck{} annotationEnabled := fmt.Sprintf("%s/enabled", r.ControllerDomain) - + checklyFinalizer := fmt.Sprintf("%s/finalizer", r.ControllerDomain) // Check if ingress object is still present - err := r.Get(ctx, req.NamespacedName, ingress) + err := r.Get(ctx, req.NamespacedName, &ingress) if err != nil { if errors.IsNotFound(err) { logger.Info("Ingress got deleted") - return ctrl.Result{}, nil + return ctrl.Result{}, client.IgnoreNotFound(err) } logger.Error(err, "Can't read the Ingress object") return ctrl.Result{}, err } logger.Info("Ingress Object found") - // Check if annotation is present on the object - checklyAnnotation := ingress.Annotations[annotationEnabled] == "true" - if !checklyAnnotation { - // Annotation may have been removed or updated, we have to determine if we need to delete a previously created ApiCheck resource - logger.Info("annotation is not present, checking if ApiCheck was created") - err = r.Get(ctx, req.NamespacedName, apiCheck) - if err != nil { - logger.Info("Apicheck not present") + // Gather data for the checkly check + logger.Info("Gathering data for the check") + apiCheckResources, err := r.gatherApiCheckData(&ingress) + if err != nil { + logger.Error(err, "unable to gather data for the apiCheck resource", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + return ctrl.Result{}, err + } + + // Do we want to do anything with the ingress? + if value, exists := ingress.Annotations[annotationEnabled]; !exists || value == "false" { + logger.Info("Checking to see if we need to delete any resources as we're not handling this ingress", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + + r.deleteIngressApiChecks(ctx, req, apiCheckResources, ingress) + + if ingress.GetDeletionTimestamp() == nil { return ctrl.Result{}, nil } - logger.Info("ApiCheck is present, but we need to delete it") - err = r.Delete(ctx, apiCheck) + } + // //////////////////////////////// + // Delete Logic + // /////////////////////////////// + + if ingress.GetDeletionTimestamp() != nil { + if controllerutil.ContainsFinalizer(&ingress, checklyFinalizer) { + logger.Info("Finalizer present, need to delete ApiCheck first", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + + r.deleteIngressApiChecks(ctx, req, apiCheckResources, ingress) + + // Delete finalizer logic + logger.Info("Deleting finalizer", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + controllerutil.RemoveFinalizer(&ingress, checklyFinalizer) + err = r.Update(ctx, &ingress) + if err != nil { + logger.Error(err, "Failed to delete finalizer", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + return ctrl.Result{}, err + } + logger.Info("Successfully deleted finalizer", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + return ctrl.Result{}, nil + } + } + + // ///////////////////////////// + // Finalizer logic + // //////////////////////////// + if !controllerutil.ContainsFinalizer(&ingress, checklyFinalizer) { + controllerutil.AddFinalizer(&ingress, checklyFinalizer) + err = r.Update(ctx, &ingress) if err != nil { - logger.Info("Failed to delete ApiCheck") + logger.Error(err, "Failed to update ingress finalizer") return ctrl.Result{}, err - // } } - + logger.Info("Added finalizer", "ingress", ingress.Name, "Ingress namespace", ingress.Namespace) return ctrl.Result{}, nil } - // Gather data for the checkly check - apiCheckSpec, err := r.gatherApiCheckData(ingress) + // ///////////////////////////// + // Update/Create logic + // //////////////////////////// + + newApiChecks, deleteApiChecks, updateApiChecks, err := r.compareApiChecks(ctx, &ingress, apiCheckResources) if err != nil { - logger.Info("unable to gather data for the apiCheck resource") + logger.Error(err, "Failed to list existing API checks") return ctrl.Result{}, err } - // Check and see if the ApiCheck has been created before - err = r.Get(ctx, req.NamespacedName, apiCheck) - if err == nil { - logger.Info("apiCheck exists, doing an update") - // We can reference the exiting apiCheck object that the server returned - apiCheck.Spec = apiCheckSpec - err = r.Update(ctx, apiCheck) + // Create new Api Checks + for _, apiCheck := range newApiChecks { + logger.Info("Creating ApiCheck", "ApiCheck Name", apiCheck.Name, "ApiCheck spec", apiCheck.Spec) + err = r.Create(ctx, apiCheck) if err != nil { + logger.Error(err, "Failed to create ApiCheck", "APICheck name", apiCheck.Name, "Namespace", apiCheck.Namespace, "Ingress name", ingress.Name, "Ingress namespace", ingress.Namespace) return ctrl.Result{}, err } - return ctrl.Result{}, nil } - // Create apiCheck - // We need to write the k8s spec resources as it is a new object - newApiCheck := &checklyv1alpha1.ApiCheck{ - ObjectMeta: metav1.ObjectMeta{ - Name: ingress.Name, - Namespace: ingress.Namespace, - OwnerReferences: []metav1.OwnerReference{ - *metav1.NewControllerRef(ingress, networkingv1.SchemeGroupVersion.WithKind("ingress")), - }, - }, - Spec: apiCheckSpec, + // Update API checks + for _, apiCheck := range updateApiChecks { + logger.Info("Updating ApiCheck", "ApiCheck Name", apiCheck.Name) + err = r.Update(ctx, apiCheck) + if err != nil { + logger.Error(err, "Failed to update APICheck resource", "APICheck name", apiCheck.Name, "Namespace", apiCheck.Namespace, "Ingress name", ingress.Name, "Ingress namespace", ingress.Namespace) + return ctrl.Result{}, err + } } - err = r.Create(ctx, newApiCheck) - if err != nil { - logger.Info("Failed to create ApiCheck", "err", err) - return ctrl.Result{}, err + // Delete old API checks + for _, apiCheck := range deleteApiChecks { + + logger.Info("Delete ApiCheck", "ApiCheck Name", apiCheck.Name) + err = r.Delete(ctx, apiCheck) + if err != nil { + logger.Error(err, "Failed to delete ApiCheck resource", "APICheck name", apiCheck.Name, "Namespace", apiCheck.Namespace, "Ingress name", ingress.Name, "Ingress namespace", ingress.Namespace) + return ctrl.Result{}, err + } } return ctrl.Result{}, nil @@ -139,7 +179,12 @@ func (r *IngressReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *IngressReconciler) gatherApiCheckData(ingress *networkingv1.Ingress) (apiCheckSpec checklyv1alpha1.ApiCheckSpec, err error) { +func (r *IngressReconciler) gatherApiCheckData( + ingress *networkingv1.Ingress, +) ( + apiChecks []*checklyv1alpha1.ApiCheck, + err error, +) { annotationHost := r.ControllerDomain annotationPath := fmt.Sprintf("%s/path", annotationHost) @@ -148,35 +193,17 @@ func (r *IngressReconciler) gatherApiCheckData(ingress *networkingv1.Ingress) (a annotationGroup := fmt.Sprintf("%s/group", annotationHost) annotationMuted := fmt.Sprintf("%s/muted", annotationHost) - // Construct the endpoint - path := "" - if ingress.Annotations[annotationPath] != "" { - path = ingress.Annotations[annotationPath] - } - - var host string - if ingress.Annotations[annotationEndpoint] == "" { - host = ingress.Spec.Rules[0].Host - } else { - host = ingress.Annotations[annotationEndpoint] - } - - endpoint := fmt.Sprintf("https://%s%s", host, path) - // Expected success code - var success string - if ingress.Annotations[annotationSuccess] != "" { - success = ingress.Annotations[annotationSuccess] - } else { + success := ingress.Annotations[annotationSuccess] + if success == "" { success = "200" } // Group - var group string - if ingress.Annotations[annotationGroup] != "" { - group = ingress.Annotations[annotationGroup] - } else { + group := ingress.Annotations[annotationGroup] + if group == "" { err = fmt.Errorf("could not find a value for the group annotation, can't continue without one") + return } // Muted @@ -187,13 +214,164 @@ func (r *IngressReconciler) gatherApiCheckData(ingress *networkingv1.Ingress) (a muted = true } - apiCheckSpec = checklyv1alpha1.ApiCheckSpec{ - Endpoint: endpoint, - Group: group, - Success: success, - Muted: muted, + labels := make(map[string]string) + labels["ingress-controller"] = ingress.Name + + // Get the host(s) and path(s) from the ingress object + for _, rule := range ingress.Spec.Rules { + + // Get the host + host := ingress.Annotations[annotationEndpoint] + if host == "" { + host = rule.Host + } + + // Get the path(s) + var paths []string + + if rule.HTTP == nil { // HTTP may not exist + paths = append(paths, "/") + } else if rule.HTTP.Paths == nil { // Paths may not exist + paths = append(paths, "/") + } else { + for _, rulePath := range rule.HTTP.Paths { + if ingress.Annotations[annotationPath] == "" { + if rulePath.Path == "" { + paths = append(paths, "/") + } else { + paths = append(paths, rulePath.Path) + } + } else { + paths = append(paths, ingress.Annotations[annotationPath]) + } + } + } + + for _, path := range paths { + // Replace path / + path = strings.TrimPrefix(path, "/") + + // Set apiCheck Name + checkName := fmt.Sprintf("%s-%s-%s", ingress.Name, host, path) + checkName = strings.Replace(checkName, "/", "", -1) + checkName = strings.Replace(checkName, ".", "", -1) + checkName = strings.Trim(checkName, "-") + + // Set endpoint + endpoint := fmt.Sprintf("https://%s/%s", host, path) + + // Construct ApiCheck Spec + apiCheckSpec := &checklyv1alpha1.ApiCheckSpec{ + Endpoint: endpoint, + Group: group, + Success: success, + Muted: muted, + } + + newApiCheck := &checklyv1alpha1.ApiCheck{ + ObjectMeta: metav1.ObjectMeta{ + Name: checkName, + Namespace: ingress.Namespace, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(ingress, networkingv1.SchemeGroupVersion.WithKind("ingress")), + }, + Labels: labels, + }, + Spec: *apiCheckSpec, + } + + apiChecks = append(apiChecks, newApiCheck) + } } // Last return return } + +func (r *IngressReconciler) compareApiChecks( + ctx context.Context, + ingress *networkingv1.Ingress, + ingressApiChecks []*checklyv1alpha1.ApiCheck, +) ( + newApiChecks []*checklyv1alpha1.ApiCheck, + deleteApiChecks []*checklyv1alpha1.ApiCheck, + updateApiChecks []*checklyv1alpha1.ApiCheck, + err error, +) { + + logger := log.FromContext(ctx) + + var existingApiChecks checklyv1alpha1.ApiCheckList + labels := make(map[string]*string) + labels["ingress-controller"] = &ingress.Name + err = r.List(ctx, &existingApiChecks, client.InNamespace(ingress.Namespace), client.MatchingLabels{"ingress-controller": ingress.Name}) + if err != nil { + return + } + + existingApiChecksMap := make(map[string]checklyv1alpha1.ApiCheck) + for _, existingApiCheck := range existingApiChecks.Items { + existingApiChecksMap[existingApiCheck.Name] = existingApiCheck + } + + newApiChecksMap := make(map[string]*checklyv1alpha1.ApiCheck) + for _, ingressApiCheck := range ingressApiChecks { + newApiChecksMap[ingressApiCheck.Name] = ingressApiCheck + } + + // Compare items + for _, existingApiCheck := range existingApiChecksMap { + newApiCheck, exists := newApiChecksMap[existingApiCheck.Name] + if exists { + if existingApiCheck.Spec == newApiCheck.Spec { + logger.Info("ApiCheck data is identical, no need for update", "ApiCheck Name", existingApiCheck.Name) + } else { + logger.Info( + "ApiCheck data is not identical, update needed", "ApiCheck Name", existingApiCheck.Name, "old spec", existingApiCheck.Spec, "new spec", newApiCheck.Spec) + updateApiChecks = append(updateApiChecks, newApiChecksMap[existingApiCheck.Name]) + } + + // Remove items from new api checks map + delete(newApiChecksMap, existingApiCheck.Name) + } else { + logger.Info("ApiCheck is not needed anymore, delete", "ApiCheck Name", existingApiCheck.Name) + deleteApiChecks = append(deleteApiChecks, &existingApiCheck) + } + } + + // Loop over remaining items and add them to the new checks list, these will be created + for _, newApiCheck := range newApiChecksMap { + newApiChecks = append(newApiChecks, newApiCheck) + } + + return +} + +func (r *IngressReconciler) deleteIngressApiChecks( + ctx context.Context, + req ctrl.Request, + apiCheckResources []*checklyv1alpha1.ApiCheck, + ingress networkingv1.Ingress, +) { + + logger := log.FromContext(ctx) + + for _, apiCheckResource := range apiCheckResources { + + logger.Info("Checking if ApiCheck was created", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + err := r.Get(ctx, req.NamespacedName, apiCheckResource) + if err != nil { + logger.Info("ApiCheck resource is not present, we don't need to do anything", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + continue + } + + logger.Info("ApiCheck resource is present, we need to delete it", "Ingress Name", ingress.Name, "Ingress namespace", ingress.Namespace) + err = r.Delete(ctx, apiCheckResource) + if err != nil { + logger.Error(err, "Failed to delete ApiCheck", "Name", apiCheckResource.Name, "Namespace", apiCheckResource.Namespace) + continue + } + + logger.Info("ApiCheck resource deleted successfully", apiCheckResource.Name, "Namespace", apiCheckResource.Namespace) + } +} diff --git a/internal/controller/networking/ingress_controller_test.go b/internal/controller/networking/ingress_controller_test.go index dc6d107..883859a 100644 --- a/internal/controller/networking/ingress_controller_test.go +++ b/internal/controller/networking/ingress_controller_test.go @@ -39,58 +39,85 @@ var _ = Describe("Ingress Controller", func() { testGroup := "ingress-group" testSuccessCode := "200" - key := types.NamespacedName{ + apiCheckName := fmt.Sprintf("%s-%s-%s", "test-ingress", "foobar", testPath) + + group := &checklyv1alpha1.Group{ + ObjectMeta: metav1.ObjectMeta{ + Name: testGroup, + }, + Spec: checklyv1alpha1.GroupSpec{ + Locations: []string{"eu-west-1"}, + }, + } + + ingressKey := types.NamespacedName{ Name: "test-ingress", Namespace: "default", } + apiCheckKey := types.NamespacedName{ + Name: apiCheckName, + Namespace: "default", + } + annotation := make(map[string]string) annotation["testing.domain.tld/enabled"] = "true" - annotation["testing.domain.tld/path"] = testPath annotation["testing.domain.tld/success"] = testSuccessCode annotation["testing.domain.tld/group"] = testGroup - rules := make([]networkingv1.IngressRule, 0) + pathTypeImplementationSpecific := networkingv1.PathTypeImplementationSpecific + + var rules []networkingv1.IngressRule rules = append(rules, networkingv1.IngressRule{ Host: testHost, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + networkingv1.HTTPIngressPath{ + Path: fmt.Sprintf("/%s", testPath), + PathType: &pathTypeImplementationSpecific, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 7777, + }, + }, + }, + }, + }, + }, + }, }) ingress := &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, + Name: ingressKey.Name, + Namespace: ingressKey.Namespace, Annotations: annotation, }, Spec: networkingv1.IngressSpec{ Rules: rules, - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test-service", - Port: networkingv1.ServiceBackendPort{ - Number: 7777, - }, - }, - }, }, } + // Create group + Expect(k8sClient.Create(context.Background(), group)).Should(Succeed()) + // Create Expect(k8sClient.Create(context.Background(), ingress)).Should(Succeed()) By("Expecting submitted") Eventually(func() bool { f := &networkingv1.Ingress{} - err := k8sClient.Get(context.Background(), key, f) - if err != nil { - return false - } - return true + err := k8sClient.Get(context.Background(), ingressKey, f) + return err == nil }, timeout, interval).Should(BeTrue()) By("Expecting ApiCheck and OwnerReference to exist") Eventually(func() bool { f := &checklyv1alpha1.ApiCheck{} - err := k8sClient.Get(context.Background(), key, f) + err := k8sClient.Get(context.Background(), apiCheckKey, f) if err != nil { return false } @@ -99,120 +126,72 @@ var _ = Describe("Ingress Controller", func() { return false } - Expect(f.Spec.Endpoint == fmt.Sprintf("https://%s%s", testHost, testPath)).To(BeTrue()) - Expect(f.Spec.Group).To(Equal(testGroup)) - Expect(f.Spec.Success).To(Equal(testSuccessCode)) - Expect(f.Spec.Muted).To(Equal(true)) + Expect(f.Spec.Endpoint == fmt.Sprintf("https://%s/%s", testHost, testPath)).To(BeTrue(), "Hosts should match.") + Expect(f.Spec.Group).To(Equal(testGroup), "Group should match") + Expect(f.Spec.Success).To(Equal(testSuccessCode), "Success code should match") + Expect(f.Spec.Muted).To(Equal(true), "Mute should match") for _, o := range f.OwnerReferences { - if o.Name != key.Name { - return false - } + Expect(o.Name).To(Equal(ingressKey.Name), "OwnerReference should be equal") } return true - }, timeout, interval).Should(BeTrue()) - - // Update - updatePath := "baaz" - updateHost := "foo.update" - annotation["testing.domain.tld/path"] = updatePath - annotation["testing.domain.tld/endpoint"] = updateHost - annotation["testing.domain.tld/success"] = "" - annotation["testing.domain.tld/muted"] = "false" - ingress = &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, - Annotations: annotation, - }, - Spec: networkingv1.IngressSpec{ - Rules: rules, - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test-service", - Port: networkingv1.ServiceBackendPort{ - Number: 7777, - }, - }, - }, - }, - } - Expect(k8sClient.Update(context.Background(), ingress)).Should(Succeed()) + }, timeout, interval).Should(BeTrue(), "Timed out waiting for success") - By("Expecting ApiCheck to be updated") - Eventually(func() bool { - f := &checklyv1alpha1.ApiCheck{} - err := k8sClient.Get(context.Background(), key, f) + // Set enabled false + By("Expecting enabled false to remove ApiCheck") + Eventually(func() error { + // Get existing ingress object + f := &networkingv1.Ingress{} + err := k8sClient.Get(context.Background(), ingressKey, f) if err != nil { - return false + return err } - if f.Spec.Endpoint != fmt.Sprintf("https://%s%s", updateHost, updatePath) { - return false + // Update annotations with `enabled` set to false + f.Annotations["testing.domain.tld/enabled"] = "false" + err = k8sClient.Update(context.Background(), f) + if err != nil { + return err } - if f.Spec.Success != "200" { - return false + u := &networkingv1.Ingress{} + err = k8sClient.Get(context.Background(), ingressKey, u) + if err != nil { + return err } - if f.Spec.Muted { - return false - } + Expect(u.Annotations["testing.domain.tld/enabled"]).To(Equal("false"), "Enabled annotation should be false") - return true - }, timeout, interval).Should(BeTrue()) + // Expect API Check to not exist anymore + Expect(k8sClient.Get(context.Background(), apiCheckKey, f)).ShouldNot(Succeed()) - // Remove enabled label - annotation["testing.domain.tld/enabled"] = "false" - ingress = &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, - Annotations: annotation, - }, - Spec: networkingv1.IngressSpec{ - Rules: rules, - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test-service", - Port: networkingv1.ServiceBackendPort{ - Number: 7777, - }, - }, - }, - }, - } - Expect(k8sClient.Update(context.Background(), ingress)).Should(Succeed()) - - // Expect ApiCheck to be deleted - By("Expecting APICheck to be deleted") - Eventually(func() error { - f := &checklyv1alpha1.ApiCheck{} - return k8sClient.Get(context.Background(), key, f) - }, timeout, interval).ShouldNot(Succeed()) + return nil + }, timeout, interval).Should(Succeed(), "Timeout waiting for update") // Delete By("Expecting to delete successfully") Eventually(func() error { f := &networkingv1.Ingress{} - k8sClient.Get(context.Background(), key, f) + k8sClient.Get(context.Background(), ingressKey, f) return k8sClient.Delete(context.Background(), f) }, timeout, interval).Should(Succeed()) By("Expecting delete to finish") Eventually(func() error { f := &networkingv1.Ingress{} - return k8sClient.Get(context.Background(), key, f) + return k8sClient.Get(context.Background(), ingressKey, f) }, timeout, interval).ShouldNot(Succeed()) + // Delete group + Expect(k8sClient.Delete(context.Background(), group)).Should(Succeed(), "Group deletion should succeed") + }) // Testing failures It("Some failures", func() { testHost := "foo.bar" testPath := "baz" - // testGroup := "ingress-group" testSuccessCode := "200" key := types.NamespacedName{ @@ -251,14 +230,6 @@ var _ = Describe("Ingress Controller", func() { } Expect(k8sClient.Create(context.Background(), ingress)).Should(Succeed()) - time.Sleep(time.Second * 5) - - updated := &networkingv1.Ingress{} - Expect(k8sClient.Get(context.Background(), key, updated)).Should(Succeed()) - annotation["testing.domain.tld/enabled"] = "true" - updated.Annotations = annotation - Expect(k8sClient.Update(context.Background(), updated)).Should(Succeed()) - // Delete By("Expecting to delete successfully") Eventually(func() error { diff --git a/internal/controller/networking/suite_test.go b/internal/controller/networking/suite_test.go index 8fc90b7..4a1c187 100644 --- a/internal/controller/networking/suite_test.go +++ b/internal/controller/networking/suite_test.go @@ -17,6 +17,8 @@ limitations under the License. package networking import ( + "encoding/json" + "net/http" "os" "path/filepath" "testing" @@ -32,14 +34,17 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "github.com/checkly/checkly-go-sdk" checklyv1alpha1 "github.com/checkly/checkly-operator/api/checkly/v1alpha1" + //+kubebuilder:scaffold:imports + internalController "github.com/checkly/checkly-operator/internal/controller/checkly" ) // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -var cfg *rest.Config +// var cfg *rest.Config var k8sClient client.Client var testEnv *envtest.Environment @@ -62,7 +67,8 @@ var _ = BeforeSuite(func() { var err error // cfg is defined in this file globally. - cfg, err := testEnv.Start() + var cfg *rest.Config + cfg, err = testEnv.Start() Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) @@ -92,6 +98,80 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + // Stub checkly client + testClient := checkly.NewClient( + "http://localhost:5557", + "foobarbaz", + nil, + nil, + ) + testClient.SetAccountId("1234567890") + go func() { + http.HandleFunc("/v1/checks", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + resp := make(map[string]string) + resp["id"] = "2" + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + }) + http.HandleFunc("/v1/checks/2", 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]string) + resp["id"] = "2" + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + case "DELETE": + w.WriteHeader(http.StatusNoContent) + } + }) + http.HandleFunc("/v1/check-groups", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + resp := make(map[string]interface{}) + resp["id"] = 1 + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + }) + http.HandleFunc("/v1/check-groups/1", 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"] = 1 + jsonResp, _ := json.Marshal(resp) + w.Write(jsonResp) + case "DELETE": + w.WriteHeader(http.StatusNoContent) + } + }) + http.ListenAndServe(":5557", nil) + }() + + err = (&internalController.ApiCheckReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + ApiClient: testClient, + ControllerDomain: testControllerDomain, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + err = (&internalController.GroupReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + ApiClient: testClient, + ControllerDomain: testControllerDomain, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctrl.SetupSignalHandler())