From bcdac27f92aff23f04a3f8fadd42b7de212aeac2 Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Mon, 9 Dec 2024 20:43:54 -0800 Subject: [PATCH 1/8] feat(apiChecks): support Assertions --- api/checkly/v1alpha1/apicheck_types.go | 40 +++-- api/checkly/v1alpha1/zz_generated.deepcopy.go | 22 ++- .../bases/k8s.checklyhq.com_apichecks.yaml | 42 +++++- docs/api-checks.md | 12 ++ external/checkly/check.go | 83 +++++------ external/checkly/check_test.go | 139 +++++++++--------- .../controller/checkly/apicheck_controller.go | 15 ++ .../checkly/apicheck_controller_test.go | 24 ++- 8 files changed, 239 insertions(+), 138 deletions(-) diff --git a/api/checkly/v1alpha1/apicheck_types.go b/api/checkly/v1alpha1/apicheck_types.go index e9b4a56..ce22ab3 100644 --- a/api/checkly/v1alpha1/apicheck_types.go +++ b/api/checkly/v1alpha1/apicheck_types.go @@ -23,28 +23,44 @@ import ( // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. +// Assertion defines a single validation condition for the API check +type Assertion struct { + // Source of the assertion (e.g., STATUS_CODE, JSON_BODY, etc.) + Source string `json:"source"` + + // Property to validate, e.g., a JSONPath expression like $.result (optional) + Property string `json:"property,omitempty"` + + // Comparison operation (e.g., EQUALS, NOT_NULL, etc.) + Comparison string `json:"comparison"` + + // Target value for the comparison (optional) + Target string `json:"target,omitempty"` +} + // ApiCheckSpec defines the desired state of ApiCheck type ApiCheckSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file + // Endpoint determines which URL to monitor, e.g., https://foo.bar/baz + Endpoint string `json:"endpoint"` - // Frequency is used to determine the frequency of the checks in minutes, default 5 + // Frequency determines the frequency of the checks in minutes, default 5 Frequency int `json:"frequency,omitempty"` + // Group determines in which group the check belongs + Group string `json:"group"` + + // MaxResponseTime determines the maximum number of milliseconds + // that can pass before the check fails, default 15000 + MaxResponseTime int `json:"maxresponsetime,omitempty"` + // Muted determines if the created alert is muted or not, default false Muted bool `json:"muted,omitempty"` - // Endpoint determines which URL to monitor, ex. https://foo.bar/baz - Endpoint string `json:"endpoint"` - - // Success determines the returned success code, ex. 200 + // Success determines the expected HTTP status code, e.g., 200 Success string `json:"success"` - // MaxResponseTime determines what the maximum number of miliseconds can pass before the check fails, default 15000 - MaxResponseTime int `json:"maxresponsetime,omitempty"` - - // Group determines in which group does the check belong to - Group string `json:"group"` + // Assertions define the validation conditions for the check + Assertions []Assertion `json:"assertions,omitempty"` } // ApiCheckStatus defines the observed state of ApiCheck diff --git a/api/checkly/v1alpha1/zz_generated.deepcopy.go b/api/checkly/v1alpha1/zz_generated.deepcopy.go index fa4bcf5..d99b0d7 100644 --- a/api/checkly/v1alpha1/zz_generated.deepcopy.go +++ b/api/checkly/v1alpha1/zz_generated.deepcopy.go @@ -136,7 +136,7 @@ func (in *ApiCheck) DeepCopyInto(out *ApiCheck) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) out.Status = in.Status } @@ -193,6 +193,11 @@ func (in *ApiCheckList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApiCheckSpec) DeepCopyInto(out *ApiCheckSpec) { *out = *in + if in.Assertions != nil { + in, out := &in.Assertions, &out.Assertions + *out = make([]Assertion, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApiCheckSpec. @@ -220,6 +225,21 @@ func (in *ApiCheckStatus) DeepCopy() *ApiCheckStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Assertion) DeepCopyInto(out *Assertion) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Assertion. +func (in *Assertion) DeepCopy() *Assertion { + if in == nil { + return nil + } + out := new(Assertion) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Group) DeepCopyInto(out *Group) { *out = *in diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index 1c1c300..23af9d1 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -57,27 +57,53 @@ spec: spec: description: ApiCheckSpec defines the desired state of ApiCheck properties: + assertions: + description: Assertions define the validation conditions for the check + items: + description: Assertion defines a single validation condition for + the API check + properties: + comparison: + description: Comparison operation (e.g., EQUALS, NOT_NULL, etc.) + type: string + property: + description: Property to validate, e.g., a JSONPath expression + like $.result (optional) + type: string + source: + description: Source of the assertion (e.g., STATUS_CODE, JSON_BODY, + etc.) + type: string + target: + description: Target value for the comparison (optional) + type: string + required: + - comparison + - source + type: object + type: array endpoint: - description: Endpoint determines which URL to monitor, ex. https://foo.bar/baz + description: Endpoint determines which URL to monitor, e.g., https://foo.bar/baz type: string frequency: - description: Frequency is used to determine the frequency of the checks - in minutes, default 5 + description: Frequency determines the frequency of the checks in minutes, + default 5 type: integer group: - description: Group determines in which group does the check belong - to + description: Group determines in which group the check belongs type: string maxresponsetime: - description: MaxResponseTime determines what the maximum number of - miliseconds can pass before the check fails, default 15000 + description: |- + MaxResponseTime determines the maximum number of milliseconds + that can pass before the check fails, default 15000 type: integer muted: description: Muted determines if the created alert is muted or not, default false type: boolean success: - description: Success determines the returned success code, ex. 200 + description: Success determines the expected HTTP status code, e.g., + 200 type: string required: - endpoint diff --git a/docs/api-checks.md b/docs/api-checks.md index c5feb70..2b3c2c7 100644 --- a/docs/api-checks.md +++ b/docs/api-checks.md @@ -30,6 +30,7 @@ Any `metadata.labels` specified will be transformed into tags, for example `envi | `frequency` | Integer; Frequency of minutes between each check, possible values: 1,2,5,10,15,30,60,120,180 | `5`| | `muted` | Bool; Is the check muted or not | `false` | | `maxresponsetime` | Integer; Number of milliseconds to wait for a response | `15000` | +| `assertions` | Array; a list of conditions to validate the check’s response | none (*optional) | ### Example @@ -47,6 +48,13 @@ spec: frequency: 10 # Default 5 muted: true # Default "false" group: "checkly-operator-test-group" + assertions: + - source: "STATUS_CODE" + comparison: "EQUALS" + target: "200" + - source: "JSON_BODY" + property: "$.status" + comparison: "NOT_NULL" --- apiVersion: k8s.checklyhq.com/v1alpha1 kind: ApiCheck @@ -59,4 +67,8 @@ spec: endpoint: "https://foo.bar/baaz" success: "200" group: "checkly-operator-test-group" + assertions: + - source: "STATUS_CODE" + comparison: "EQUALS" + target: "200" ``` diff --git a/external/checkly/check.go b/external/checkly/check.go index 5bb775b..6b3bbb7 100644 --- a/external/checkly/check.go +++ b/external/checkly/check.go @@ -37,19 +37,21 @@ type Check struct { ID string Muted bool Labels map[string]string + Assertions []checkly.Assertion // Align with checkly's Assertion struct } func checklyCheck(apiCheck Check) (check checkly.Check, err error) { - + // Ensure `shouldFail` logic is handled shouldFail, err := shouldFail(apiCheck.SuccessCode) if err != nil { return } + // Map tags from labels and namespace tags := getTags(apiCheck.Labels) - tags = append(tags, "checkly-operator") - tags = append(tags, apiCheck.Namespace) + tags = append(tags, "checkly-operator", apiCheck.Namespace) + // Define alert settings alertSettings := checkly.AlertSettings{ EscalationType: checkly.RunBased, RunBasedEscalation: checkly.RunBasedEscalation{ @@ -67,49 +69,44 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { }, } + // Default assertion logic: If no assertions are provided, add a default + assertions := apiCheck.Assertions + if len(assertions) == 0 { + assertions = []checkly.Assertion{ + { + Source: checkly.StatusCode, + Comparison: checkly.Equals, + Target: apiCheck.SuccessCode, + }, + } + } + + // Create the Checkly API check structure check = checkly.Check{ - Name: apiCheck.Name, - Type: checkly.TypeAPI, - Frequency: checkValueInt(apiCheck.Frequency, 5), - DegradedResponseTime: 5000, - MaxResponseTime: checkValueInt(apiCheck.MaxResponseTime, 15000), - Activated: true, - Muted: apiCheck.Muted, // muted for development - ShouldFail: shouldFail, - DoubleCheck: false, - SSLCheck: false, - LocalSetupScript: "", - LocalTearDownScript: "", - Locations: []string{}, - Tags: tags, - AlertSettings: alertSettings, - UseGlobalAlertSettings: false, - GroupID: apiCheck.GroupID, + Name: apiCheck.Name, + Type: checkly.TypeAPI, + Frequency: checkValueInt(apiCheck.Frequency, 5), + DegradedResponseTime: 5000, + MaxResponseTime: checkValueInt(apiCheck.MaxResponseTime, 15000), + Activated: true, + Muted: apiCheck.Muted, + ShouldFail: shouldFail, + DoubleCheck: false, + SSLCheck: false, + AlertSettings: alertSettings, + Locations: []string{}, + Tags: tags, Request: checkly.Request{ - Method: http.MethodGet, - URL: apiCheck.Endpoint, - Headers: []checkly.KeyValue{ - // { - // Key: "X-Test", - // Value: "foo", - // }, - }, - QueryParameters: []checkly.KeyValue{ - // { - // Key: "query", - // Value: "foo", - // }, - }, - Assertions: []checkly.Assertion{ - { - Source: checkly.StatusCode, - Comparison: checkly.Equals, - Target: apiCheck.SuccessCode, - }, - }, - Body: "", - BodyType: "NONE", + Method: http.MethodGet, + URL: apiCheck.Endpoint, + Assertions: assertions, + Headers: []checkly.KeyValue{}, + QueryParameters: []checkly.KeyValue{}, + Body: "", + BodyType: "NONE", }, + UseGlobalAlertSettings: false, + GroupID: apiCheck.GroupID, } return diff --git a/external/checkly/check_test.go b/external/checkly/check_test.go index 4c93032..f39adaa 100644 --- a/external/checkly/check_test.go +++ b/external/checkly/check_test.go @@ -25,8 +25,7 @@ import ( ) func TestChecklyCheck(t *testing.T) { - - data1 := Check{ + data := Check{ Name: "foo", Namespace: "bar", Frequency: 15, @@ -34,70 +33,66 @@ func TestChecklyCheck(t *testing.T) { Endpoint: "https://foo.bar/baz", SuccessCode: "403", Muted: true, + Assertions: []checkly.Assertion{ + { + Source: "JSONBody", + Property: "$.result", + Comparison: "Equals", + Target: "false", + }, + { + Source: "JSONBody", + Comparison: "NotNull", + }, + }, + } + + testData, err := checklyCheck(data) + if err != nil { + t.Fatalf("Expected no error, got %v", err) } - testData, _ := checklyCheck(data1) - - if testData.Name != data1.Name { - t.Errorf("Expected %s, got %s", data1.Name, testData.Name) - } - - if testData.Frequency != data1.Frequency { - t.Errorf("Expected %d, got %d", data1.Frequency, testData.Frequency) - } - - if testData.MaxResponseTime != data1.MaxResponseTime { - t.Errorf("Expected %d, got %d", data1.MaxResponseTime, testData.MaxResponseTime) - } - - if testData.Muted != data1.Muted { - t.Errorf("Expected %t, got %t", data1.Muted, testData.Muted) + if testData.Name != data.Name { + t.Errorf("Expected %s, got %s", data.Name, testData.Name) } - if testData.ShouldFail != true { - t.Errorf("Expected %t, got %t", true, testData.ShouldFail) + if testData.Frequency != data.Frequency { + t.Errorf("Expected %d, got %d", data.Frequency, testData.Frequency) } - data2 := Check{ - Name: "foo", - Namespace: "bar", - Endpoint: "https://foo.bar/baz", - SuccessCode: "200", + if testData.MaxResponseTime != data.MaxResponseTime { + t.Errorf("Expected %d, got %d", data.MaxResponseTime, testData.MaxResponseTime) } - testData, _ = checklyCheck(data2) - - if testData.Frequency != 5 { - t.Errorf("Expected %d, got %d", 5, testData.Frequency) + if len(testData.Request.Assertions) != len(data.Assertions) { + t.Errorf("Expected %d assertions, got %d", len(data.Assertions), len(testData.Request.Assertions)) } - if testData.MaxResponseTime != 15000 { - t.Errorf("Expected %d, got %d", 15000, testData.MaxResponseTime) + for i, assertion := range testData.Request.Assertions { + if assertion.Source != data.Assertions[i].Source { + t.Errorf("Expected Source %s, got %s", data.Assertions[i].Source, assertion.Source) + } + if assertion.Comparison != data.Assertions[i].Comparison { + t.Errorf("Expected Comparison %s, got %s", data.Assertions[i].Comparison, assertion.Comparison) + } + if assertion.Target != data.Assertions[i].Target { + t.Errorf("Expected Target %s, got %s", data.Assertions[i].Target, assertion.Target) + } } - if testData.ShouldFail != false { - t.Errorf("Expected %t, got %t", false, testData.ShouldFail) + if testData.Muted != data.Muted { + t.Errorf("Expected %t, got %t", data.Muted, testData.Muted) } - failData := Check{ - Name: "fail", - Namespace: "bar", - Endpoint: "https://foo.bar/baz", - SuccessCode: "foo", - } - - _, err := checklyCheck(failData) - if err == nil { - t.Error("Expected error, got nil") + if testData.ShouldFail != true { + t.Errorf("Expected true for ShouldFail, got false") } - - return } func TestChecklyCheckActions(t *testing.T) { - expectedCheckID := "2" expectedGroupID := 1 + testData := Check{ Name: "foo", Namespace: "bar", @@ -106,15 +101,28 @@ func TestChecklyCheckActions(t *testing.T) { Endpoint: "https://foo.bar/baz", SuccessCode: "200", ID: "", - } - - // Test errors + Assertions: []checkly.Assertion{ + { + Source: "StatusCode", + Comparison: "Equals", + Target: "200", + }, + { + Source: "JSONBody", + Property: "$.result", + Comparison: "NotNull", + }, + }, + } + + // Test client for failure simulation testClientFail := checkly.NewClient( "http://localhost:5556", "foobarbaz", nil, nil, ) + // Create _, err := Create(testData, testClientFail) if err == nil { @@ -133,7 +141,7 @@ func TestChecklyCheckActions(t *testing.T) { t.Error("Expected error, got none") } - // Test happy path + // Test client for happy path testClient := checkly.NewClient( "http://localhost:5555", "foobarbaz", @@ -142,25 +150,23 @@ func TestChecklyCheckActions(t *testing.T) { ) testClient.SetAccountId("1234567890") + // Mock server for happy path 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"] = expectedCheckID + resp := map[string]string{"id": expectedCheckID} jsonResp, _ := json.Marshal(resp) w.Write(jsonResp) return }) http.HandleFunc("/v1/checks/2", func(w http.ResponseWriter, r *http.Request) { r.ParseForm() - method := r.Method - switch method { + switch r.Method { case "PUT": w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - resp := make(map[string]string) - resp["id"] = expectedCheckID + resp := map[string]string{"id": expectedCheckID} jsonResp, _ := json.Marshal(resp) w.Write(jsonResp) case "DELETE": @@ -171,21 +177,18 @@ func TestChecklyCheckActions(t *testing.T) { 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"] = expectedGroupID + resp := map[string]interface{}{"id": expectedGroupID} jsonResp, _ := json.Marshal(resp) w.Write(jsonResp) return }) http.HandleFunc("/v1/check-groups/1", func(w http.ResponseWriter, r *http.Request) { r.ParseForm() - method := r.Method - switch method { + switch r.Method { case "PUT": w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - resp := make(map[string]interface{}) - resp["id"] = expectedGroupID + resp := map[string]interface{}{"id": expectedGroupID} jsonResp, _ := json.Marshal(resp) w.Write(jsonResp) case "DELETE": @@ -196,28 +199,28 @@ func TestChecklyCheckActions(t *testing.T) { http.ListenAndServe(":5555", nil) }() + // Create testID, err := Create(testData, testClient) if err != nil { - t.Errorf("Expected no error, got %e", err) + t.Errorf("Expected no error, got %v", err) } if testID != expectedCheckID { t.Errorf("Expected %s, got %s", expectedCheckID, testID) } + // Update testData.ID = expectedCheckID - err = Update(testData, testClient) if err != nil { - t.Errorf("Expected no error, got %e", err) + t.Errorf("Expected no error, got %v", err) } + // Delete err = Delete(expectedCheckID, testClient) if err != nil { - t.Errorf("Expected no error, got %e", err) + t.Errorf("Expected no error, got %v", err) } - - return } func TestShouldFail(t *testing.T) { diff --git a/internal/controller/checkly/apicheck_controller.go b/internal/controller/checkly/apicheck_controller.go index 47099fe..2ec0c46 100644 --- a/internal/controller/checkly/apicheck_controller.go +++ b/internal/controller/checkly/apicheck_controller.go @@ -150,6 +150,7 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c GroupID: group.Status.ID, Muted: apiCheck.Spec.Muted, Labels: apiCheck.Labels, + Assertions: r.mapAssertions(apiCheck.Spec.Assertions), } // ///////////////////////////// @@ -194,6 +195,20 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{}, nil } +// mapAssertions maps ApiCheck assertions to external.Check assertions +func (r *ApiCheckReconciler) mapAssertions(assertions []checklyv1alpha1.Assertion) []checkly.Assertion { + var mapped []checkly.Assertion + for _, assertion := range assertions { + mapped = append(mapped, checkly.Assertion{ + Source: assertion.Source, + Property: assertion.Property, + Comparison: assertion.Comparison, + Target: assertion.Target, + }) + } + return mapped +} + // SetupWithManager sets up the controller with the Manager. func (r *ApiCheckReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/checkly/apicheck_controller_test.go b/internal/controller/checkly/apicheck_controller_test.go index d19339e..c699818 100644 --- a/internal/controller/checkly/apicheck_controller_test.go +++ b/internal/controller/checkly/apicheck_controller_test.go @@ -47,12 +47,8 @@ var _ = Describe("ApiCheck Controller", func() { // Add any teardown steps that needs to be executed after each test }) - // 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("ApiCheck", func() { - It("Full reconciliation", func() { + It("Full reconciliation with assertions", func() { key := types.NamespacedName{ Name: "test-apicheck", @@ -79,6 +75,18 @@ var _ = Describe("ApiCheck Controller", func() { Success: "200", Group: groupKey.Name, Muted: true, + Assertions: []checklyv1alpha1.Assertion{ + { + Source: "STATUS_CODE", + Comparison: "EQUALS", + Target: "200", + }, + { + Source: "JSON_BODY", + Property: "$.status", + Comparison: "NOT_NULL", + }, + }, }, } @@ -97,7 +105,7 @@ var _ = Describe("ApiCheck Controller", func() { }, timeout, interval).Should(BeTrue()) // Status.ID should be present - By("Expecting group ID") + By("Expecting group ID and assertions") Eventually(func() bool { f := &checklyv1alpha1.ApiCheck{} err := k8sClient.Get(context.Background(), key, f) @@ -109,6 +117,10 @@ var _ = Describe("ApiCheck Controller", func() { return false } + if len(f.Spec.Assertions) != 2 { + return false + } + return true }, timeout, interval).Should(BeTrue()) From 328acb1eaf89ef3b2712f184be1e0ee5eaf5257f Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Mon, 9 Dec 2024 22:26:41 -0800 Subject: [PATCH 2/8] feat(apiChecks): support dynamic http method --- api/checkly/v1alpha1/apicheck_types.go | 6 +++--- .../crd/bases/k8s.checklyhq.com_apichecks.yaml | 4 ++++ external/checkly/check.go | 9 ++++++++- external/checkly/check_test.go | 11 ++++++++--- .../controller/checkly/apicheck_controller.go | 17 ++++------------- .../checkly/apicheck_controller_test.go | 9 +++++++-- 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/api/checkly/v1alpha1/apicheck_types.go b/api/checkly/v1alpha1/apicheck_types.go index ce22ab3..27387e0 100644 --- a/api/checkly/v1alpha1/apicheck_types.go +++ b/api/checkly/v1alpha1/apicheck_types.go @@ -20,9 +20,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - // Assertion defines a single validation condition for the API check type Assertion struct { // Source of the assertion (e.g., STATUS_CODE, JSON_BODY, etc.) @@ -59,6 +56,9 @@ type ApiCheckSpec struct { // Success determines the expected HTTP status code, e.g., 200 Success string `json:"success"` + // Method defines the HTTP method to use for the check, e.g., GET, POST, PUT (default is GET) + Method string `json:"method,omitempty"` + // Assertions define the validation conditions for the check Assertions []Assertion `json:"assertions,omitempty"` } diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index 23af9d1..9405fc0 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -97,6 +97,10 @@ spec: MaxResponseTime determines the maximum number of milliseconds that can pass before the check fails, default 15000 type: integer + method: + description: Method defines the HTTP method to use for the check, + e.g., GET, POST, PUT (default is GET) + type: string muted: description: Muted determines if the created alert is muted or not, default false diff --git a/external/checkly/check.go b/external/checkly/check.go index 6b3bbb7..cb5b7ef 100644 --- a/external/checkly/check.go +++ b/external/checkly/check.go @@ -38,6 +38,7 @@ type Check struct { Muted bool Labels map[string]string Assertions []checkly.Assertion // Align with checkly's Assertion struct + Method string // HTTP method to use for the check } func checklyCheck(apiCheck Check) (check checkly.Check, err error) { @@ -81,6 +82,12 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { } } + // Determine the HTTP method; default to GET if not specified + method := http.MethodGet + if apiCheck.Method != "" { + method = apiCheck.Method + } + // Create the Checkly API check structure check = checkly.Check{ Name: apiCheck.Name, @@ -97,7 +104,7 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { Locations: []string{}, Tags: tags, Request: checkly.Request{ - Method: http.MethodGet, + Method: method, URL: apiCheck.Endpoint, Assertions: assertions, Headers: []checkly.KeyValue{}, diff --git a/external/checkly/check_test.go b/external/checkly/check_test.go index f39adaa..8969723 100644 --- a/external/checkly/check_test.go +++ b/external/checkly/check_test.go @@ -33,6 +33,7 @@ func TestChecklyCheck(t *testing.T) { Endpoint: "https://foo.bar/baz", SuccessCode: "403", Muted: true, + Method: "POST", Assertions: []checkly.Assertion{ { Source: "JSONBody", @@ -80,6 +81,10 @@ func TestChecklyCheck(t *testing.T) { } } + if testData.Request.Method != data.Method { + t.Errorf("Expected Method %s, got %s", data.Method, testData.Request.Method) + } + if testData.Muted != data.Muted { t.Errorf("Expected %t, got %t", data.Muted, testData.Muted) } @@ -100,6 +105,7 @@ func TestChecklyCheckActions(t *testing.T) { MaxResponseTime: 2000, Endpoint: "https://foo.bar/baz", SuccessCode: "200", + Method: "PUT", ID: "", Assertions: []checkly.Assertion{ { @@ -230,7 +236,7 @@ func TestShouldFail(t *testing.T) { testResponse, err := shouldFail(testTrue) if err != nil { - t.Errorf("Expected no error, got %e", err) + t.Errorf("Expected no error, got %v", err) } if testResponse != true { t.Errorf("Expected true, got %t", testResponse) @@ -238,7 +244,7 @@ func TestShouldFail(t *testing.T) { testResponse, err = shouldFail(testFalse) if err != nil { - t.Errorf("Expected no error, got %e", err) + t.Errorf("Expected no error, got %v", err) } if testResponse != false { t.Errorf("Expected false, got %t", testResponse) @@ -248,5 +254,4 @@ func TestShouldFail(t *testing.T) { if err == nil { t.Errorf("Expected error, got none") } - } diff --git a/internal/controller/checkly/apicheck_controller.go b/internal/controller/checkly/apicheck_controller.go index 2ec0c46..e4ba3dd 100644 --- a/internal/controller/checkly/apicheck_controller.go +++ b/internal/controller/checkly/apicheck_controller.go @@ -33,7 +33,7 @@ import ( external "github.com/checkly/checkly-operator/external/checkly" ) -// ApiCheckReconciler reconciles a ApiCheck object +// ApiCheckReconciler reconciles an ApiCheck object type ApiCheckReconciler struct { client.Client Scheme *runtime.Scheme @@ -46,15 +46,6 @@ type ApiCheckReconciler struct { //+kubebuilder:rbac:groups=k8s.checklyhq.com,resources=apichecks/finalizers,verbs=update //+kubebuilder:rbac:groups=k8s.checklyhq.com,resources=groups,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. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the ApiCheck object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.11.0/pkg/reconcile func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -134,11 +125,11 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } if group.Status.ID == 0 { - logger.V(1).Info("Group ID has not been populated, we're too quick, requeining for retry", "group name", apiCheck.Spec.Group) + logger.V(1).Info("Group ID has not been populated, we're too quick, requeuing for retry", "group name", apiCheck.Spec.Group) return ctrl.Result{Requeue: true}, nil } - // Create internal Check type + // Pass through method field without defaulting internalCheck := external.Check{ Name: apiCheck.Name, Namespace: apiCheck.Namespace, @@ -151,6 +142,7 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c Muted: apiCheck.Spec.Muted, Labels: apiCheck.Labels, Assertions: r.mapAssertions(apiCheck.Spec.Assertions), + Method: apiCheck.Spec.Method, // Pass the method field directly } // ///////////////////////////// @@ -162,7 +154,6 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // Existing object, we need to update it logger.V(1).Info("Existing object, with ID", "checkly ID", apiCheck.Status.ID, "endpoint", apiCheck.Spec.Endpoint) err := external.Update(internalCheck, r.ApiClient) - // err := if err != nil { logger.Error(err, "Failed to update the checkly check") return ctrl.Result{}, err diff --git a/internal/controller/checkly/apicheck_controller_test.go b/internal/controller/checkly/apicheck_controller_test.go index c699818..b0a18c6 100644 --- a/internal/controller/checkly/apicheck_controller_test.go +++ b/internal/controller/checkly/apicheck_controller_test.go @@ -48,7 +48,7 @@ var _ = Describe("ApiCheck Controller", func() { }) Context("ApiCheck", func() { - It("Full reconciliation with assertions", func() { + It("Full reconciliation with assertions and method", func() { key := types.NamespacedName{ Name: "test-apicheck", @@ -75,6 +75,7 @@ var _ = Describe("ApiCheck Controller", func() { Success: "200", Group: groupKey.Name, Muted: true, + Method: "POST", Assertions: []checklyv1alpha1.Assertion{ { Source: "STATUS_CODE", @@ -105,7 +106,7 @@ var _ = Describe("ApiCheck Controller", func() { }, timeout, interval).Should(BeTrue()) // Status.ID should be present - By("Expecting group ID and assertions") + By("Expecting group ID, method, and assertions") Eventually(func() bool { f := &checklyv1alpha1.ApiCheck{} err := k8sClient.Get(context.Background(), key, f) @@ -117,6 +118,10 @@ var _ = Describe("ApiCheck Controller", func() { return false } + if f.Spec.Method != "POST" { + return false + } + if len(f.Spec.Assertions) != 2 { return false } From ef95147a668f17dceb7029c70a1fe32ed6ad6a8a Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Thu, 12 Dec 2024 15:30:57 -0800 Subject: [PATCH 3/8] feat(apiChecks): support body + bodytype --- api/checkly/v1alpha1/apicheck_types.go | 9 +++-- .../bases/k8s.checklyhq.com_apichecks.yaml | 7 ++++ docs/api-checks.md | 36 ++++++++++-------- external/checkly/check.go | 37 ++++++++++++++++--- external/checkly/check_test.go | 26 ++++++++++++- .../controller/checkly/apicheck_controller.go | 5 ++- .../checkly/apicheck_controller_test.go | 14 ++++++- 7 files changed, 106 insertions(+), 28 deletions(-) diff --git a/api/checkly/v1alpha1/apicheck_types.go b/api/checkly/v1alpha1/apicheck_types.go index 27387e0..783fbce 100644 --- a/api/checkly/v1alpha1/apicheck_types.go +++ b/api/checkly/v1alpha1/apicheck_types.go @@ -59,15 +59,18 @@ type ApiCheckSpec struct { // Method defines the HTTP method to use for the check, e.g., GET, POST, PUT (default is GET) Method string `json:"method,omitempty"` + // Body defines the request payload for the check + Body string `json:"body,omitempty"` + + // BodyType specifies the format of the request payload, e.g., json, graphql, raw data (default is NONE) + BodyType string `json:"bodyType,omitempty"` + // Assertions define the validation conditions for the check Assertions []Assertion `json:"assertions,omitempty"` } // ApiCheckStatus defines the observed state of ApiCheck type ApiCheckStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file - // ID holds the checklyhq.com internal ID of the check ID string `json:"id"` diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index 9405fc0..32b1205 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -82,6 +82,13 @@ spec: - source type: object type: array + body: + description: Body defines the request payload for the check + type: string + bodyType: + description: BodyType specifies the format of the request payload, + e.g., json, graphql, raw data (default is NONE) + type: string endpoint: description: Endpoint determines which URL to monitor, e.g., https://foo.bar/baz type: string diff --git a/docs/api-checks.md b/docs/api-checks.md index 2b3c2c7..5aa1dbd 100644 --- a/docs/api-checks.md +++ b/docs/api-checks.md @@ -3,7 +3,7 @@ See the [official checkly docs](https://www.checklyhq.com/docs/api-checks/) on what API checks are. > ***Warning*** -> We currently only support GET requests for API Checks. +> The default HTTP method is GET for API Checks. Other methods like POST, PUT, and DELETE are supported but must be explicitly specified in the configuration. API Checks resources are namespace scoped, meaning they need to be unique inside a namespace and you need to add a `metadata.namespace` field to them. @@ -11,7 +11,7 @@ We can also create API Checks from `ingress` resources, see [ingress](ingress.md ## Configuration options -The name of the API check derives from the `metadata.name` of the created kubernetes resource. +The name of the API check derives from the `metadata.name` of the created Kubernetes resource. ### Labels @@ -26,11 +26,14 @@ Any `metadata.labels` specified will be transformed into tags, for example `envi |--------------|-----------|------------| | `endpoint` | String; Endpoint to run the check against | none (*required) | | `success` | String; The expected success code | none (*required) | -| `group` | String; Name of the group to which the check belongs; Kubernetes `Group` resource name` | none (*required)| -| `frequency` | Integer; Frequency of minutes between each check, possible values: 1,2,5,10,15,30,60,120,180 | `5`| +| `group` | String; Name of the group to which the check belongs; Kubernetes `Group` resource name | none (*required) | +| `frequency` | Integer; Frequency of minutes between each check, possible values: 1,2,5,10,15,30,60,120,180 | `5` | | `muted` | Bool; Is the check muted or not | `false` | | `maxresponsetime` | Integer; Number of milliseconds to wait for a response | `15000` | -| `assertions` | Array; a list of conditions to validate the check’s response | none (*optional) | +| `method` | String; HTTP method to use (e.g., GET, POST, PUT, DELETE) | `GET` | +| `body` | String; Payload for the HTTP request, if applicable | `""` (empty) | +| `bodyType` | String; Format of the body (e.g., json, graphql, raw data) | `""` (none) | +| `assertions` | Array; A list of conditions to validate the check’s response | none (*optional) | ### Example @@ -48,13 +51,16 @@ spec: frequency: 10 # Default 5 muted: true # Default "false" group: "checkly-operator-test-group" + method: "POST" + body: '{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}' + bodyType: "json" assertions: - - source: "STATUS_CODE" - comparison: "EQUALS" - target: "200" - - source: "JSON_BODY" - property: "$.status" - comparison: "NOT_NULL" + - source: "STATUS_CODE" + comparison: "EQUALS" + target: "200" + - source: "JSON_BODY" + property: "$.status" + comparison: "NOT_NULL" --- apiVersion: k8s.checklyhq.com/v1alpha1 kind: ApiCheck @@ -67,8 +73,8 @@ spec: endpoint: "https://foo.bar/baaz" success: "200" group: "checkly-operator-test-group" + method: "GET" assertions: - - source: "STATUS_CODE" - comparison: "EQUALS" - target: "200" -``` + - source: "STATUS_CODE" + comparison: "EQUALS" + target: "200" diff --git a/external/checkly/check.go b/external/checkly/check.go index cb5b7ef..737df56 100644 --- a/external/checkly/check.go +++ b/external/checkly/check.go @@ -18,14 +18,16 @@ package external import ( "context" + "encoding/json" "net/http" "strconv" + "strings" "time" "github.com/checkly/checkly-go-sdk" ) -// Check is a struct for the internal packages to help put together the checkly check +// Check struct for internal packages to help put together the checkly check type Check struct { Name string Namespace string @@ -37,8 +39,10 @@ type Check struct { ID string Muted bool Labels map[string]string - Assertions []checkly.Assertion // Align with checkly's Assertion struct - Method string // HTTP method to use for the check + Assertions []checkly.Assertion + Method string + Body string + BodyType string } func checklyCheck(apiCheck Check) (check checkly.Check, err error) { @@ -88,6 +92,29 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { method = apiCheck.Method } + // Determine the body type and body; default to empty if not specified + body := apiCheck.Body + bodyType := apiCheck.BodyType + if bodyType == "" { + bodyType = "NONE" // Default body type + } + + // Reformat the body if BodyType is JSON + if bodyType == "json" { + var jsonBody map[string]interface{} + err := json.Unmarshal([]byte(body), &jsonBody) + if err != nil { + return check, fmt.Errorf("invalid JSON body: %w", err) + } + + formattedBody, err := json.Marshal(jsonBody) + if err != nil { + return check, fmt.Errorf("failed to format JSON body: %w", err) + } + + body = string(formattedBody) + } + // Create the Checkly API check structure check = checkly.Check{ Name: apiCheck.Name, @@ -109,8 +136,8 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { Assertions: assertions, Headers: []checkly.KeyValue{}, QueryParameters: []checkly.KeyValue{}, - Body: "", - BodyType: "NONE", + Body: body, + BodyType: bodyType, }, UseGlobalAlertSettings: false, GroupID: apiCheck.GroupID, diff --git a/external/checkly/check_test.go b/external/checkly/check_test.go index 8969723..651991c 100644 --- a/external/checkly/check_test.go +++ b/external/checkly/check_test.go @@ -34,6 +34,8 @@ func TestChecklyCheck(t *testing.T) { SuccessCode: "403", Muted: true, Method: "POST", + Body: `{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}`, + BodyType: "json", Assertions: []checkly.Assertion{ { Source: "JSONBody", @@ -85,6 +87,21 @@ func TestChecklyCheck(t *testing.T) { t.Errorf("Expected Method %s, got %s", data.Method, testData.Request.Method) } + if testData.Request.BodyType != data.BodyType { + t.Errorf("Expected BodyType %s, got %s", data.BodyType, testData.Request.BodyType) + } + + expectedBody := `{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}` + var expectedBodyFormatted map[string]interface{} + json.Unmarshal([]byte(expectedBody), &expectedBodyFormatted) + + var actualBodyFormatted map[string]interface{} + json.Unmarshal([]byte(testData.Request.Body), &actualBodyFormatted) + + if !equalJSON(expectedBodyFormatted, actualBodyFormatted) { + t.Errorf("Expected Body %v, got %v", expectedBodyFormatted, actualBodyFormatted) + } + if testData.Muted != data.Muted { t.Errorf("Expected %t, got %t", data.Muted, testData.Muted) } @@ -106,6 +123,8 @@ func TestChecklyCheckActions(t *testing.T) { Endpoint: "https://foo.bar/baz", SuccessCode: "200", Method: "PUT", + Body: `{"query":"query { status }"}`, + BodyType: "graphql", ID: "", Assertions: []checkly.Assertion{ { @@ -121,7 +140,6 @@ func TestChecklyCheckActions(t *testing.T) { }, } - // Test client for failure simulation testClientFail := checkly.NewClient( "http://localhost:5556", "foobarbaz", @@ -255,3 +273,9 @@ func TestShouldFail(t *testing.T) { t.Errorf("Expected error, got none") } } + +func equalJSON(expected, actual map[string]interface{}) bool { + expectedBytes, _ := json.Marshal(expected) + actualBytes, _ := json.Marshal(actual) + return string(expectedBytes) == string(actualBytes) +} diff --git a/internal/controller/checkly/apicheck_controller.go b/internal/controller/checkly/apicheck_controller.go index e4ba3dd..6b05f7f 100644 --- a/internal/controller/checkly/apicheck_controller.go +++ b/internal/controller/checkly/apicheck_controller.go @@ -129,7 +129,6 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{Requeue: true}, nil } - // Pass through method field without defaulting internalCheck := external.Check{ Name: apiCheck.Name, Namespace: apiCheck.Namespace, @@ -142,7 +141,9 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c Muted: apiCheck.Spec.Muted, Labels: apiCheck.Labels, Assertions: r.mapAssertions(apiCheck.Spec.Assertions), - Method: apiCheck.Spec.Method, // Pass the method field directly + Method: apiCheck.Spec.Method, + Body: apiCheck.Spec.Body, + BodyType: apiCheck.Spec.BodyType, } // ///////////////////////////// diff --git a/internal/controller/checkly/apicheck_controller_test.go b/internal/controller/checkly/apicheck_controller_test.go index b0a18c6..9ace939 100644 --- a/internal/controller/checkly/apicheck_controller_test.go +++ b/internal/controller/checkly/apicheck_controller_test.go @@ -48,7 +48,7 @@ var _ = Describe("ApiCheck Controller", func() { }) Context("ApiCheck", func() { - It("Full reconciliation with assertions and method", func() { + It("Full reconciliation with body and body type", func() { key := types.NamespacedName{ Name: "test-apicheck", @@ -76,6 +76,8 @@ var _ = Describe("ApiCheck Controller", func() { Group: groupKey.Name, Muted: true, Method: "POST", + Body: `{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}`, + BodyType: "json", Assertions: []checklyv1alpha1.Assertion{ { Source: "STATUS_CODE", @@ -106,7 +108,7 @@ var _ = Describe("ApiCheck Controller", func() { }, timeout, interval).Should(BeTrue()) // Status.ID should be present - By("Expecting group ID, method, and assertions") + By("Expecting group ID, method, body, body type, and assertions") Eventually(func() bool { f := &checklyv1alpha1.ApiCheck{} err := k8sClient.Get(context.Background(), key, f) @@ -122,6 +124,14 @@ var _ = Describe("ApiCheck Controller", func() { return false } + if f.Spec.Body != `{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}` { + return false + } + + if f.Spec.BodyType != "json" { + return false + } + if len(f.Spec.Assertions) != 2 { return false } From 9106ed1c656570e530b353213b43b6ba1a7a9907 Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Thu, 12 Dec 2024 15:40:58 -0800 Subject: [PATCH 4/8] reformat 1 --- api/checkly/v1alpha1/apicheck_types.go | 23 ++++++++++++------- .../bases/k8s.checklyhq.com_apichecks.yaml | 15 ++++++------ docs/api-checks.md | 2 +- external/checkly/check.go | 13 +++-------- external/checkly/check_test.go | 16 +++++-------- .../controller/checkly/apicheck_controller.go | 3 ++- .../checkly/apicheck_controller_test.go | 4 ++++ 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/api/checkly/v1alpha1/apicheck_types.go b/api/checkly/v1alpha1/apicheck_types.go index 783fbce..2bab847 100644 --- a/api/checkly/v1alpha1/apicheck_types.go +++ b/api/checkly/v1alpha1/apicheck_types.go @@ -20,7 +20,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// Assertion defines a single validation condition for the API check +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. type Assertion struct { // Source of the assertion (e.g., STATUS_CODE, JSON_BODY, etc.) Source string `json:"source"` @@ -37,15 +38,12 @@ type Assertion struct { // ApiCheckSpec defines the desired state of ApiCheck type ApiCheckSpec struct { - // Endpoint determines which URL to monitor, e.g., https://foo.bar/baz - Endpoint string `json:"endpoint"` + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file - // Frequency determines the frequency of the checks in minutes, default 5 + // Frequency is used to determine the frequency of the checks in minutes, default 5 Frequency int `json:"frequency,omitempty"` - // Group determines in which group the check belongs - Group string `json:"group"` - // MaxResponseTime determines the maximum number of milliseconds // that can pass before the check fails, default 15000 MaxResponseTime int `json:"maxresponsetime,omitempty"` @@ -53,12 +51,18 @@ type ApiCheckSpec struct { // Muted determines if the created alert is muted or not, default false Muted bool `json:"muted,omitempty"` - // Success determines the expected HTTP status code, e.g., 200 + // Endpoint determines which URL to monitor, ex. https://foo.bar/baz + Endpoint string `json:"endpoint"` + + // Success determines the returned success code, ex. 200 Success string `json:"success"` // Method defines the HTTP method to use for the check, e.g., GET, POST, PUT (default is GET) Method string `json:"method,omitempty"` + // Group determines in which group does the check belong to + Group string `json:"group"` + // Body defines the request payload for the check Body string `json:"body,omitempty"` @@ -71,6 +75,9 @@ type ApiCheckSpec struct { // ApiCheckStatus defines the observed state of ApiCheck type ApiCheckStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + // ID holds the checklyhq.com internal ID of the check ID string `json:"id"` diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index 32b1205..7fbf4d4 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -93,16 +93,16 @@ spec: description: Endpoint determines which URL to monitor, e.g., https://foo.bar/baz type: string frequency: - description: Frequency determines the frequency of the checks in minutes, - default 5 + description: Frequency is used to determine the frequency of the checks + in minutes, default 5 type: integer group: - description: Group determines in which group the check belongs + description: Group determines in which group does the check belong + to type: string maxresponsetime: - description: |- - MaxResponseTime determines the maximum number of milliseconds - that can pass before the check fails, default 15000 + description: MaxResponseTime determines what the maximum number of + miliseconds can pass before the check fails, default 15000 type: integer method: description: Method defines the HTTP method to use for the check, @@ -113,8 +113,7 @@ spec: default false type: boolean success: - description: Success determines the expected HTTP status code, e.g., - 200 + description: Success determines the returned success code, ex. 200 type: string required: - endpoint diff --git a/docs/api-checks.md b/docs/api-checks.md index 5aa1dbd..d0f267c 100644 --- a/docs/api-checks.md +++ b/docs/api-checks.md @@ -11,7 +11,7 @@ We can also create API Checks from `ingress` resources, see [ingress](ingress.md ## Configuration options -The name of the API check derives from the `metadata.name` of the created Kubernetes resource. +The name of the API check derives from the `metadata.name` of the created kubernetes resource. ### Labels diff --git a/external/checkly/check.go b/external/checkly/check.go index 737df56..aa74eb4 100644 --- a/external/checkly/check.go +++ b/external/checkly/check.go @@ -27,7 +27,7 @@ import ( "github.com/checkly/checkly-go-sdk" ) -// Check struct for internal packages to help put together the checkly check +// Check is a struct for the internal packages to help put together the checkly check type Check struct { Name string Namespace string @@ -46,17 +46,15 @@ type Check struct { } func checklyCheck(apiCheck Check) (check checkly.Check, err error) { - // Ensure `shouldFail` logic is handled + shouldFail, err := shouldFail(apiCheck.SuccessCode) if err != nil { return } - // Map tags from labels and namespace tags := getTags(apiCheck.Labels) tags = append(tags, "checkly-operator", apiCheck.Namespace) - // Define alert settings alertSettings := checkly.AlertSettings{ EscalationType: checkly.RunBased, RunBasedEscalation: checkly.RunBasedEscalation{ @@ -74,7 +72,6 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { }, } - // Default assertion logic: If no assertions are provided, add a default assertions := apiCheck.Assertions if len(assertions) == 0 { assertions = []checkly.Assertion{ @@ -86,20 +83,17 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { } } - // Determine the HTTP method; default to GET if not specified method := http.MethodGet if apiCheck.Method != "" { method = apiCheck.Method } - // Determine the body type and body; default to empty if not specified body := apiCheck.Body bodyType := apiCheck.BodyType if bodyType == "" { - bodyType = "NONE" // Default body type + bodyType = "NONE" } - // Reformat the body if BodyType is JSON if bodyType == "json" { var jsonBody map[string]interface{} err := json.Unmarshal([]byte(body), &jsonBody) @@ -115,7 +109,6 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { body = string(formattedBody) } - // Create the Checkly API check structure check = checkly.Check{ Name: apiCheck.Name, Type: checkly.TypeAPI, diff --git a/external/checkly/check_test.go b/external/checkly/check_test.go index 651991c..5040661 100644 --- a/external/checkly/check_test.go +++ b/external/checkly/check_test.go @@ -112,9 +112,9 @@ func TestChecklyCheck(t *testing.T) { } func TestChecklyCheckActions(t *testing.T) { + expectedCheckID := "2" expectedGroupID := 1 - testData := Check{ Name: "foo", Namespace: "bar", @@ -140,13 +140,13 @@ func TestChecklyCheckActions(t *testing.T) { }, } + // Test errors testClientFail := checkly.NewClient( "http://localhost:5556", "foobarbaz", nil, nil, ) - // Create _, err := Create(testData, testClientFail) if err == nil { @@ -165,7 +165,7 @@ func TestChecklyCheckActions(t *testing.T) { t.Error("Expected error, got none") } - // Test client for happy path + // Test happy path testClient := checkly.NewClient( "http://localhost:5555", "foobarbaz", @@ -174,7 +174,6 @@ func TestChecklyCheckActions(t *testing.T) { ) testClient.SetAccountId("1234567890") - // Mock server for happy path go func() { http.HandleFunc("/v1/checks", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusCreated) @@ -223,7 +222,6 @@ func TestChecklyCheckActions(t *testing.T) { http.ListenAndServe(":5555", nil) }() - // Create testID, err := Create(testData, testClient) if err != nil { t.Errorf("Expected no error, got %v", err) @@ -233,17 +231,15 @@ func TestChecklyCheckActions(t *testing.T) { t.Errorf("Expected %s, got %s", expectedCheckID, testID) } - // Update testData.ID = expectedCheckID err = Update(testData, testClient) if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Errorf("Expected no error, got %e", err) } - // Delete err = Delete(expectedCheckID, testClient) if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Errorf("Expected no error, got %e", err) } } @@ -262,7 +258,7 @@ func TestShouldFail(t *testing.T) { testResponse, err = shouldFail(testFalse) if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Errorf("Expected no error, got %e", err) } if testResponse != false { t.Errorf("Expected false, got %t", testResponse) diff --git a/internal/controller/checkly/apicheck_controller.go b/internal/controller/checkly/apicheck_controller.go index 6b05f7f..5300541 100644 --- a/internal/controller/checkly/apicheck_controller.go +++ b/internal/controller/checkly/apicheck_controller.go @@ -33,7 +33,7 @@ import ( external "github.com/checkly/checkly-operator/external/checkly" ) -// ApiCheckReconciler reconciles an ApiCheck object +// ApiCheckReconciler reconciles a ApiCheck object type ApiCheckReconciler struct { client.Client Scheme *runtime.Scheme @@ -129,6 +129,7 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{Requeue: true}, nil } + // Create internal Check type internalCheck := external.Check{ Name: apiCheck.Name, Namespace: apiCheck.Namespace, diff --git a/internal/controller/checkly/apicheck_controller_test.go b/internal/controller/checkly/apicheck_controller_test.go index 9ace939..89a9b1c 100644 --- a/internal/controller/checkly/apicheck_controller_test.go +++ b/internal/controller/checkly/apicheck_controller_test.go @@ -47,6 +47,10 @@ var _ = Describe("ApiCheck Controller", func() { // Add any teardown steps that needs to be executed after each test }) + // 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("ApiCheck", func() { It("Full reconciliation with body and body type", func() { From 031c51baa066fba9a2d85761819075a6e8b7b31d Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Thu, 12 Dec 2024 15:44:44 -0800 Subject: [PATCH 5/8] reformat 2 --- api/checkly/v1alpha1/apicheck_types.go | 7 +++---- config/crd/bases/k8s.checklyhq.com_apichecks.yaml | 2 +- external/checkly/check_test.go | 6 ++++-- internal/controller/checkly/apicheck_controller.go | 9 +++++++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/api/checkly/v1alpha1/apicheck_types.go b/api/checkly/v1alpha1/apicheck_types.go index 2bab847..ea9afde 100644 --- a/api/checkly/v1alpha1/apicheck_types.go +++ b/api/checkly/v1alpha1/apicheck_types.go @@ -44,10 +44,6 @@ type ApiCheckSpec struct { // Frequency is used to determine the frequency of the checks in minutes, default 5 Frequency int `json:"frequency,omitempty"` - // MaxResponseTime determines the maximum number of milliseconds - // that can pass before the check fails, default 15000 - MaxResponseTime int `json:"maxresponsetime,omitempty"` - // Muted determines if the created alert is muted or not, default false Muted bool `json:"muted,omitempty"` @@ -57,6 +53,9 @@ type ApiCheckSpec struct { // Success determines the returned success code, ex. 200 Success string `json:"success"` + // MaxResponseTime determines what the maximum number of miliseconds can pass before the check fails, default 15000 + MaxResponseTime int `json:"maxresponsetime,omitempty"` + // Method defines the HTTP method to use for the check, e.g., GET, POST, PUT (default is GET) Method string `json:"method,omitempty"` diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index 7fbf4d4..6f22470 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -90,7 +90,7 @@ spec: e.g., json, graphql, raw data (default is NONE) type: string endpoint: - description: Endpoint determines which URL to monitor, e.g., https://foo.bar/baz + description: Endpoint determines which URL to monitor, ex. https://foo.bar/baz type: string frequency: description: Frequency is used to determine the frequency of the checks diff --git a/external/checkly/check_test.go b/external/checkly/check_test.go index 5040661..55c004b 100644 --- a/external/checkly/check_test.go +++ b/external/checkly/check_test.go @@ -224,7 +224,7 @@ func TestChecklyCheckActions(t *testing.T) { testID, err := Create(testData, testClient) if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Errorf("Expected no error, got %e", err) } if testID != expectedCheckID { @@ -241,6 +241,8 @@ func TestChecklyCheckActions(t *testing.T) { if err != nil { t.Errorf("Expected no error, got %e", err) } + + return } func TestShouldFail(t *testing.T) { @@ -250,7 +252,7 @@ func TestShouldFail(t *testing.T) { testResponse, err := shouldFail(testTrue) if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Errorf("Expected no error, got %e", err) } if testResponse != true { t.Errorf("Expected true, got %t", testResponse) diff --git a/internal/controller/checkly/apicheck_controller.go b/internal/controller/checkly/apicheck_controller.go index 5300541..844a826 100644 --- a/internal/controller/checkly/apicheck_controller.go +++ b/internal/controller/checkly/apicheck_controller.go @@ -46,6 +46,15 @@ type ApiCheckReconciler struct { //+kubebuilder:rbac:groups=k8s.checklyhq.com,resources=apichecks/finalizers,verbs=update //+kubebuilder:rbac:groups=k8s.checklyhq.com,resources=groups,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. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the ApiCheck object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.11.0/pkg/reconcile func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) From ae9d304a4cf68050701fe79f44fe355dfbadb2a4 Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Thu, 12 Dec 2024 15:46:48 -0800 Subject: [PATCH 6/8] fix typos --- config/crd/bases/k8s.checklyhq.com_apichecks.yaml | 5 +++-- external/checkly/check.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index 6f22470..a9cbe2c 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -60,8 +60,9 @@ spec: assertions: description: Assertions define the validation conditions for the check items: - description: Assertion defines a single validation condition for - the API check + description: |- + EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! + NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. properties: comparison: description: Comparison operation (e.g., EQUALS, NOT_NULL, etc.) diff --git a/external/checkly/check.go b/external/checkly/check.go index aa74eb4..5204a71 100644 --- a/external/checkly/check.go +++ b/external/checkly/check.go @@ -19,9 +19,9 @@ package external import ( "context" "encoding/json" + "fmt" "net/http" "strconv" - "strings" "time" "github.com/checkly/checkly-go-sdk" From c0a3f60c1a0133f062378a9f22632e5ee7fbe18f Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Mon, 16 Dec 2024 16:08:34 -0800 Subject: [PATCH 7/8] remove success out of CRD --- .../bases/k8s.checklyhq.com_apichecks.yaml | 8 ----- external/checkly/check.go | 34 +++++++------------ external/checkly/check_test.go | 34 +------------------ .../controller/checkly/apicheck_controller.go | 1 - 4 files changed, 13 insertions(+), 64 deletions(-) diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index a9cbe2c..41803f0 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -19,10 +19,6 @@ spec: jsonPath: .spec.endpoint name: Endpoint type: string - - description: Expected status code - jsonPath: .spec.success - name: Status code - type: string - jsonPath: .spec.muted name: Muted type: boolean @@ -113,13 +109,9 @@ spec: description: Muted determines if the created alert is muted or not, default false type: boolean - success: - description: Success determines the returned success code, ex. 200 - type: string required: - endpoint - group - - success type: object status: description: ApiCheckStatus defines the observed state of ApiCheck diff --git a/external/checkly/check.go b/external/checkly/check.go index 5204a71..1e0431a 100644 --- a/external/checkly/check.go +++ b/external/checkly/check.go @@ -21,7 +21,7 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" + "strings" "time" "github.com/checkly/checkly-go-sdk" @@ -34,7 +34,6 @@ type Check struct { Frequency int MaxResponseTime int Endpoint string - SuccessCode string GroupID int64 ID string Muted bool @@ -47,11 +46,6 @@ type Check struct { func checklyCheck(apiCheck Check) (check checkly.Check, err error) { - shouldFail, err := shouldFail(apiCheck.SuccessCode) - if err != nil { - return - } - tags := getTags(apiCheck.Labels) tags = append(tags, "checkly-operator", apiCheck.Namespace) @@ -72,15 +66,23 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { }, } + shouldFail := false assertions := apiCheck.Assertions if len(assertions) == 0 { assertions = []checkly.Assertion{ { Source: checkly.StatusCode, Comparison: checkly.Equals, - Target: apiCheck.SuccessCode, + Target: "200", }, } + } else { + for _, assertion := range assertions { + if assertion.Source == checkly.StatusCode && assertion.Comparison == checkly.Equals && assertion.Target >= "400" { + shouldFail = true + break + } + } } method := http.MethodGet @@ -89,12 +91,12 @@ func checklyCheck(apiCheck Check) (check checkly.Check, err error) { } body := apiCheck.Body - bodyType := apiCheck.BodyType + bodyType := strings.ToUpper(apiCheck.BodyType) if bodyType == "" { bodyType = "NONE" } - if bodyType == "json" { + if bodyType == "JSON" { var jsonBody map[string]interface{} err := json.Unmarshal([]byte(body), &jsonBody) if err != nil { @@ -186,15 +188,3 @@ func Delete(ID string, client checkly.Client) (err error) { return } - -func shouldFail(successCode string) (bool, error) { - code, err := strconv.Atoi(successCode) - if err != nil { - return false, err - } - if code < 400 { - return false, nil - } else { - return true, nil - } -} diff --git a/external/checkly/check_test.go b/external/checkly/check_test.go index 55c004b..1f2dc36 100644 --- a/external/checkly/check_test.go +++ b/external/checkly/check_test.go @@ -31,11 +31,10 @@ func TestChecklyCheck(t *testing.T) { Frequency: 15, MaxResponseTime: 2000, Endpoint: "https://foo.bar/baz", - SuccessCode: "403", Muted: true, Method: "POST", Body: `{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}`, - BodyType: "json", + BodyType: "JSON", Assertions: []checkly.Assertion{ { Source: "JSONBody", @@ -106,9 +105,6 @@ func TestChecklyCheck(t *testing.T) { t.Errorf("Expected %t, got %t", data.Muted, testData.Muted) } - if testData.ShouldFail != true { - t.Errorf("Expected true for ShouldFail, got false") - } } func TestChecklyCheckActions(t *testing.T) { @@ -121,7 +117,6 @@ func TestChecklyCheckActions(t *testing.T) { Frequency: 15, MaxResponseTime: 2000, Endpoint: "https://foo.bar/baz", - SuccessCode: "200", Method: "PUT", Body: `{"query":"query { status }"}`, BodyType: "graphql", @@ -245,33 +240,6 @@ func TestChecklyCheckActions(t *testing.T) { return } -func TestShouldFail(t *testing.T) { - testTrue := "401" - testFalse := "200" - testErr := "foo" - - testResponse, err := shouldFail(testTrue) - if err != nil { - t.Errorf("Expected no error, got %e", err) - } - if testResponse != true { - t.Errorf("Expected true, got %t", testResponse) - } - - testResponse, err = shouldFail(testFalse) - if err != nil { - t.Errorf("Expected no error, got %e", err) - } - if testResponse != false { - t.Errorf("Expected false, got %t", testResponse) - } - - _, err = shouldFail(testErr) - if err == nil { - t.Errorf("Expected error, got none") - } -} - func equalJSON(expected, actual map[string]interface{}) bool { expectedBytes, _ := json.Marshal(expected) actualBytes, _ := json.Marshal(actual) diff --git a/internal/controller/checkly/apicheck_controller.go b/internal/controller/checkly/apicheck_controller.go index 844a826..686e11b 100644 --- a/internal/controller/checkly/apicheck_controller.go +++ b/internal/controller/checkly/apicheck_controller.go @@ -145,7 +145,6 @@ func (r *ApiCheckReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c Frequency: apiCheck.Spec.Frequency, MaxResponseTime: apiCheck.Spec.MaxResponseTime, Endpoint: apiCheck.Spec.Endpoint, - SuccessCode: apiCheck.Spec.Success, ID: apiCheck.Status.ID, GroupID: group.Status.ID, Muted: apiCheck.Spec.Muted, From cdd3a0abf132b807fa59916bfdf10b63e5424e97 Mon Sep 17 00:00:00 2001 From: abezard-conduit Date: Mon, 16 Dec 2024 19:11:32 -0800 Subject: [PATCH 8/8] reomituif empty ccess o --- api/checkly/v1alpha1/apicheck_types.go | 2 +- config/crd/bases/k8s.checklyhq.com_apichecks.yaml | 7 +++++++ internal/controller/checkly/apicheck_controller_test.go | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/api/checkly/v1alpha1/apicheck_types.go b/api/checkly/v1alpha1/apicheck_types.go index ea9afde..3766d56 100644 --- a/api/checkly/v1alpha1/apicheck_types.go +++ b/api/checkly/v1alpha1/apicheck_types.go @@ -51,7 +51,7 @@ type ApiCheckSpec struct { Endpoint string `json:"endpoint"` // Success determines the returned success code, ex. 200 - Success string `json:"success"` + Success string `json:"success,omitempty"` // MaxResponseTime determines what the maximum number of miliseconds can pass before the check fails, default 15000 MaxResponseTime int `json:"maxresponsetime,omitempty"` diff --git a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml index 41803f0..a3743c0 100644 --- a/config/crd/bases/k8s.checklyhq.com_apichecks.yaml +++ b/config/crd/bases/k8s.checklyhq.com_apichecks.yaml @@ -19,6 +19,10 @@ spec: jsonPath: .spec.endpoint name: Endpoint type: string + - description: Expected status code + jsonPath: .spec.success + name: Status code + type: string - jsonPath: .spec.muted name: Muted type: boolean @@ -109,6 +113,9 @@ spec: description: Muted determines if the created alert is muted or not, default false type: boolean + success: + description: Success determines the returned success code, ex. 200 + type: string required: - endpoint - group diff --git a/internal/controller/checkly/apicheck_controller_test.go b/internal/controller/checkly/apicheck_controller_test.go index 89a9b1c..f5a9dad 100644 --- a/internal/controller/checkly/apicheck_controller_test.go +++ b/internal/controller/checkly/apicheck_controller_test.go @@ -76,7 +76,6 @@ var _ = Describe("ApiCheck Controller", func() { }, Spec: checklyv1alpha1.ApiCheckSpec{ Endpoint: "http://bar.baz/quoz", - Success: "200", Group: groupKey.Name, Muted: true, Method: "POST",