diff --git a/slo.go b/slo.go new file mode 100644 index 0000000..1f763ef --- /dev/null +++ b/slo.go @@ -0,0 +1,71 @@ +package signalfx + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/signalfx/signalfx-go/slo" + "io" + "net/http" +) + +const SloAPIURL = "/v2/slo" + +func (c *Client) GetSlo(ctx context.Context, id string) (*slo.SloObject, error) { + return c.executeSloRequest(ctx, SloAPIURL+"/"+id, http.MethodGet, http.StatusOK, nil) +} + +func (c *Client) CreateSlo(ctx context.Context, sloRequest *slo.SloObject) (*slo.SloObject, error) { + return c.executeSloRequest(ctx, SloAPIURL, http.MethodPost, http.StatusOK, sloRequest) +} + +func (c *Client) ValidateSlo(ctx context.Context, sloRequest *slo.SloObject) error { + _, err := c.executeSloRequest(ctx, SloAPIURL+"/validate", http.MethodPost, http.StatusNoContent, sloRequest) + return err +} + +func (c *Client) UpdateSlo(ctx context.Context, id string, sloRequest *slo.SloObject) (*slo.SloObject, error) { + return c.executeSloRequest(ctx, SloAPIURL+"/"+id, http.MethodPut, http.StatusOK, sloRequest) +} + +func (c *Client) DeleteSlo(ctx context.Context, id string) error { + _, err := c.executeSloRequest(ctx, SloAPIURL+"/"+id, http.MethodDelete, http.StatusNoContent, nil) + return err +} + +func (c *Client) executeSloRequest(ctx context.Context, url string, method string, expectedValidStatus int, sloRequest *slo.SloObject) (*slo.SloObject, error) { + var body io.Reader + + if sloRequest != nil { + payload, err := json.Marshal(sloRequest) + if err != nil { + return nil, err + } + + body = bytes.NewReader(payload) + } + + resp, err := c.doRequest(ctx, method, url, nil, body) + if resp != nil { + defer resp.Body.Close() + } + + if err != nil { + return nil, err + } + + if resp.StatusCode != expectedValidStatus { + message, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Bad status %d: %s", resp.StatusCode, message) + } + + if resp.Body != nil { + returnedSlo := &slo.SloObject{} + err = json.NewDecoder(resp.Body).Decode(returnedSlo) + return returnedSlo, nil + } else { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, nil + } +} diff --git a/slo/model_slo_alert_rule_object.go b/slo/model_slo_alert_rule_object.go new file mode 100644 index 0000000..19539ab --- /dev/null +++ b/slo/model_slo_alert_rule_object.go @@ -0,0 +1,112 @@ +package slo + +import ( + "encoding/json" + "fmt" + "github.com/signalfx/signalfx-go/detector" +) + +const ( + BreachRule = "BREACH" + ErrorBudgetLeftRule = "ERROR_BUDGET_LEFT" + BurnRateRule = "BURN_RATE" +) + +type BreachSloAlertRule struct { + Rules []*BreachDetectorRule `json:"rules,omitempty"` +} + +type BreachDetectorRule struct { + detector.Rule + Parameters *BreachDetectorParameters `json:"parameters,omitempty"` +} + +type BreachDetectorParameters struct { + FireLasting string `json:"fireLasting,omitempty"` + PercentOfLasting float64 `json:"percentOfLasting,omitempty"` +} + +type ErrorBudgetLeftSloAlertRule struct { + Rules []*ErrorBudgetLeftDetectorRule `json:"rules,omitempty"` +} + +type ErrorBudgetLeftDetectorRule struct { + detector.Rule + Parameters *ErrorBudgetLeftDetectorParameters `json:"parameters,omitempty"` +} + +type ErrorBudgetLeftDetectorParameters struct { + FireLasting string `json:"fireLasting,omitempty"` + PercentOfLasting float64 `json:"percentOfLasting,omitempty"` + PercentErrorBudgetLeft float64 `json:"percentErrorBudgetLeft,omitempty"` +} + +type BurnRateSloAlertRule struct { + Rules []*BurnRateDetectorRule `json:"rules,omitempty"` +} + +type BurnRateDetectorRule struct { + detector.Rule + Parameters *BurnRateDetectorParameters `json:"parameters,omitempty"` +} + +type BurnRateDetectorParameters struct { + ShortWindow1 string `json:"shortWindow1,omitempty"` + LongWindow1 string `json:"longWindow1,omitempty"` + ShortWindow2 string `json:"shortWindow2,omitempty"` + LongWindow2 string `json:"longWindow2,omitempty"` + BurnRateThreshold1 float64 `json:"burnRateThreshold1,omitempty"` + BurnRateThreshold2 float64 `json:"burnRateThreshold2,omitempty"` +} + +type BaseSloAlertRule struct { + Type string `json:"type,omitempty"` +} + +type SloAlertRule struct { + BaseSloAlertRule + *BreachSloAlertRule + *ErrorBudgetLeftSloAlertRule + *BurnRateSloAlertRule +} + +func (rule *SloAlertRule) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &rule.BaseSloAlertRule); err != nil { + return err + } + switch rule.Type { + case BreachRule: + rule.BreachSloAlertRule = &BreachSloAlertRule{} + return json.Unmarshal(data, rule.BreachSloAlertRule) + case ErrorBudgetLeftRule: + rule.ErrorBudgetLeftSloAlertRule = &ErrorBudgetLeftSloAlertRule{} + return json.Unmarshal(data, rule.ErrorBudgetLeftSloAlertRule) + case BurnRateRule: + rule.BurnRateSloAlertRule = &BurnRateSloAlertRule{} + return json.Unmarshal(data, rule.BurnRateSloAlertRule) + default: + return fmt.Errorf("unrecognized SLO alert rule type %s", rule.Type) + } +} + +func (rule *SloAlertRule) MarshalJSON() ([]byte, error) { + switch rule.Type { + case BreachRule: + return json.Marshal(struct { + BaseSloAlertRule + *BreachSloAlertRule + }{rule.BaseSloAlertRule, rule.BreachSloAlertRule}) + case ErrorBudgetLeftRule: + return json.Marshal(struct { + BaseSloAlertRule + *ErrorBudgetLeftSloAlertRule + }{rule.BaseSloAlertRule, rule.ErrorBudgetLeftSloAlertRule}) + case BurnRateRule: + return json.Marshal(struct { + BaseSloAlertRule + *BurnRateSloAlertRule + }{rule.BaseSloAlertRule, rule.BurnRateSloAlertRule}) + default: + return nil, fmt.Errorf("unrecognized SLO alert rule type %s", rule.Type) + } +} diff --git a/slo/model_slo_object.go b/slo/model_slo_object.go new file mode 100644 index 0000000..df557cf --- /dev/null +++ b/slo/model_slo_object.go @@ -0,0 +1,81 @@ +package slo + +import ( + "encoding/json" + "fmt" +) + +const ( + RequestBased = "RequestBased" + WindowsBased = "WindowsBased" +) + +type BaseSlo struct { + Creator string `json:"creator,omitempty"` + LastUpdatedBy string `json:"lastUpdatedBy,omitempty"` + Created int64 `json:"created,omitempty"` + LastUpdated int64 `json:"lastUpdated,omitempty"` + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Targets []SloTarget `json:"targets,omitempty"` + Type string `json:"type,omitempty"` + Metadata []string `json:"metadata,omitempty"` +} + +type SloObject struct { + BaseSlo + *RequestBasedSlo + *WindowBasedSlo +} + +type RequestBasedSlo struct { + Inputs *RequestBasedSloInput `json:"inputs,omitempty"` +} + +type WindowBasedSlo struct { + Inputs *WindowBasedSloInput `json:"inputs,omitempty"` +} + +type RequestBasedSloInput struct { + ProgramText string `json:"programText,omitempty"` + GoodEventsLabel string `json:"goodEventsLabel,omitempty"` + TotalEventsLabel string `json:"totalEventsLabel,omitempty"` +} + +type WindowBasedSloInput struct { + ProgramText string `json:"programText,omitempty"` +} + +func (slo *SloObject) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &slo.BaseSlo); err != nil { + return err + } + switch slo.Type { + case RequestBased: + slo.RequestBasedSlo = &RequestBasedSlo{} + return json.Unmarshal(data, slo.RequestBasedSlo) + case WindowsBased: + slo.WindowBasedSlo = &WindowBasedSlo{} + return json.Unmarshal(data, slo.WindowBasedSlo) + default: + return fmt.Errorf("unrecognized SLO type %s", slo.Type) + } +} + +func (slo SloObject) MarshalJSON() ([]byte, error) { + switch slo.Type { + case RequestBased: + return json.Marshal(struct { + BaseSlo + *RequestBasedSlo + }{slo.BaseSlo, slo.RequestBasedSlo}) + case WindowsBased: + return json.Marshal(struct { + BaseSlo + *WindowBasedSlo + }{slo.BaseSlo, slo.WindowBasedSlo}) + default: + return nil, fmt.Errorf("unrecognized SLO type %s", slo.Type) + } +} diff --git a/slo/model_slo_target.go b/slo/model_slo_target.go new file mode 100644 index 0000000..64e357c --- /dev/null +++ b/slo/model_slo_target.go @@ -0,0 +1,65 @@ +package slo + +import ( + "encoding/json" + "fmt" +) + +const ( + RollingWindowTarget = "RollingWindow" + CalendarWindowTarget = "CalendarWindow" +) + +type SloTarget struct { + BaseSloTarget + *RollingWindowSloTarget + *CalendarWindowSloTarget +} + +type BaseSloTarget struct { + Slo float64 `json:"slo,omitempty"` + SloAlertRules []SloAlertRule `json:"sloAlertRules,omitempty"` + Type string `json:"type,omitempty"` +} + +type RollingWindowSloTarget struct { + CompliancePeriod string `json:"compliancePeriod,omitempty"` +} + +type CalendarWindowSloTarget struct { + CycleType string `json:"cycleType,omitempty"` + CycleStart string `json:"cycleStart,omitempty"` +} + +func (target *SloTarget) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &target.BaseSloTarget); err != nil { + return err + } + switch target.Type { + case RollingWindowTarget: + target.RollingWindowSloTarget = &RollingWindowSloTarget{} + return json.Unmarshal(data, target.RollingWindowSloTarget) + case CalendarWindowTarget: + target.CalendarWindowSloTarget = &CalendarWindowSloTarget{} + return json.Unmarshal(data, target.CalendarWindowSloTarget) + default: + return fmt.Errorf("unrecognized SLO target type %s", target.Type) + } +} + +func (target *SloTarget) MarshalJSON() ([]byte, error) { + switch target.Type { + case RollingWindowTarget: + return json.Marshal(struct { + BaseSloTarget + *RollingWindowSloTarget + }{target.BaseSloTarget, target.RollingWindowSloTarget}) + case CalendarWindowTarget: + return json.Marshal(struct { + BaseSloTarget + *CalendarWindowSloTarget + }{target.BaseSloTarget, target.CalendarWindowSloTarget}) + default: + return nil, fmt.Errorf("unrecognized SLO target type %s", target.Type) + } +} diff --git a/slo_test.go b/slo_test.go new file mode 100644 index 0000000..4166136 --- /dev/null +++ b/slo_test.go @@ -0,0 +1,137 @@ +package signalfx + +import ( + "context" + "fmt" + "github.com/signalfx/signalfx-go/detector" + "github.com/signalfx/signalfx-go/slo" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +const id = "12345" + +func TestGetSloWithRollingWindowTarget(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/v2/slo/%s", id), verifyRequest(t, http.MethodGet, true, http.StatusOK, nil, "slo/get_success_rolling_window_target.json")) + + result, err := client.GetSlo(context.Background(), id) + assert.NoError(t, err, "Unexpected error getting SLO") + assert.Equal(t, id, result.Id, "Id does not match") + assert.Equal(t, "SLO testing", result.Name, "Name does not match") + assert.Equal(t, 99.99, result.Targets[0].Slo, "SloObject target does not match") + assert.Equal(t, "7d", result.Targets[0].CompliancePeriod, "SloObject compliance period does not match") + assert.Equal(t, detector.MAJOR, result.Targets[0].SloAlertRules[0].BreachSloAlertRule.Rules[0].Severity, "SloObject rule severity does not match") + assert.Equal(t, "5m", result.Targets[0].SloAlertRules[0].BreachSloAlertRule.Rules[0].Parameters.FireLasting, "SloObject rule fire lasting does not match") +} + +func TestGetSloWithCalendarWindowTarget(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/v2/slo/%s", id), verifyRequest(t, "GET", true, http.StatusOK, nil, "slo/get_success_calendar_window_target.json")) + + result, err := client.GetSlo(context.Background(), id) + assert.NoError(t, err, "Unexpected error getting SLO") + assert.Equal(t, id, result.Id, "Id does not match") + assert.Equal(t, "SLO testing", result.Name, "Name does not match") + assert.Equal(t, 95.0, result.Targets[0].Slo, "SloObject target does not match") + assert.Equal(t, "month", result.Targets[0].CycleType, "SloObject cycle type does not match") + assert.Equal(t, detector.CRITICAL, result.Targets[0].SloAlertRules[0].ErrorBudgetLeftSloAlertRule.Rules[0].Severity, "SloObject rule severity does not match") + assert.Equal(t, "10m", result.Targets[0].SloAlertRules[0].ErrorBudgetLeftSloAlertRule.Rules[0].Parameters.FireLasting, "SloObject rule fire lasting does not match") +} + +func TestGetMissingSlo(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/v2/slo/%s", id), verifyRequest(t, "GET", true, http.StatusNotFound, nil, "")) + + result, err := client.GetSlo(context.Background(), "string") + assert.Error(t, err, "Expected error getting missing SLO") + assert.Nil(t, result, "Expected nil result getting SLO") +} + +func TestCreateSlo(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/slo", verifyRequest(t, "POST", true, http.StatusOK, nil, "slo/get_success_rolling_window_target.json")) + + result, err := client.CreateSlo(context.Background(), &slo.SloObject{ + BaseSlo: slo.BaseSlo{ + Type: slo.RequestBased, + }, + }) + assert.NoError(t, err, "Unexpected error creating SLO") + assert.Equal(t, id, result.Id, "Id does not match") +} + +func TestCreateBadSlo(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc("/v2/slo", verifyRequest(t, "POST", true, http.StatusBadRequest, nil, "")) + + result, err := client.CreateSlo(context.Background(), &slo.SloObject{ + BaseSlo: slo.BaseSlo{ + Type: slo.RequestBased, + }, + }) + + assert.Error(t, err, "Should have gotten an error from a bad create") + assert.Nil(t, result, "Should have a null SLO on bad create") +} + +func TestDeleteSlo(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/v2/slo/%s", id), verifyRequest(t, "DELETE", true, http.StatusNoContent, nil, "")) + + err := client.DeleteSlo(context.Background(), id) + assert.NoError(t, err, "Unexpected error deleting SLO") +} + +func TestDeleteMissingSlo(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/v2/slo/%s", id), verifyRequest(t, "DELETE", true, http.StatusNotFound, nil, "")) + + err := client.DeleteSlo(context.Background(), id) + assert.Error(t, err, "Should have gotten an error from a missing SLO") +} + +func TestUpdateSlo(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/v2/slo/%s", id), verifyRequest(t, "PUT", true, http.StatusOK, nil, "slo/get_success_rolling_window_target.json")) + + result, err := client.UpdateSlo(context.Background(), id, &slo.SloObject{ + BaseSlo: slo.BaseSlo{ + Type: slo.RequestBased, + }, + }) + assert.NoError(t, err, "Unexpected error updating SLO") + assert.Equal(t, id, result.Id, "Id does not match") +} + +func TestUpdateMissingSlo(t *testing.T) { + teardown := setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/v2/slo/%s", id), verifyRequest(t, "PUT", true, http.StatusNotFound, nil, "")) + + result, err := client.UpdateSlo(context.Background(), id, &slo.SloObject{ + BaseSlo: slo.BaseSlo{ + Type: slo.RequestBased, + }, + }) + assert.Error(t, err, "Should have gotten an error from an update on a missing SLO") + assert.Nil(t, result, "Should have gotten a nil result from an update on a missing SLO") +} diff --git a/testdata/fixtures/slo/get_success_calendar_window_target.json b/testdata/fixtures/slo/get_success_calendar_window_target.json new file mode 100644 index 0000000..fe778cb --- /dev/null +++ b/testdata/fixtures/slo/get_success_calendar_window_target.json @@ -0,0 +1,30 @@ +{ + "id": "12345", + "inputs": { + "goodEventsLabel": "G", + "programText": "G = data('spans.count', filter=filter('sf_error', 'false') and filter('sf_environment', 'lab0') and filter('sf_service', 'signalboost'))\nT = data('spans.count', filter=filter('sf_environment', 'lab0') and filter('sf_service', 'signalboost'))", + "totalEventsLabel": "T" + }, + "name": "SLO testing", + "targets": [ + { + "cycleType": "month", + "slo": 95.0, + "sloAlertRules": [ + { + "rules": [ + { + "parameters": { + "fireLasting": "10m" + }, + "severity": "Critical" + } + ], + "type": "ERROR_BUDGET_LEFT" + } + ], + "type": "CalendarWindow" + } + ], + "type": "RequestBased" +} \ No newline at end of file diff --git a/testdata/fixtures/slo/get_success_rolling_window_target.json b/testdata/fixtures/slo/get_success_rolling_window_target.json new file mode 100644 index 0000000..a3d47c4 --- /dev/null +++ b/testdata/fixtures/slo/get_success_rolling_window_target.json @@ -0,0 +1,30 @@ +{ + "id": "12345", + "inputs": { + "goodEventsLabel": "G", + "programText": "G = data('spans.count', filter=filter('sf_error', 'false') and filter('sf_environment', 'lab0') and filter('sf_service', 'signalboost'))\nT = data('spans.count', filter=filter('sf_environment', 'lab0') and filter('sf_service', 'signalboost'))", + "totalEventsLabel": "T" + }, + "name": "SLO testing", + "targets": [ + { + "compliancePeriod": "7d", + "slo": 99.99, + "sloAlertRules": [ + { + "rules": [ + { + "parameters": { + "fireLasting": "5m" + }, + "severity": "Major" + } + ], + "type": "BREACH" + } + ], + "type": "RollingWindow" + } + ], + "type": "RequestBased" +} \ No newline at end of file