diff --git a/README.md b/README.md index 4f300543..6090d114 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ metrics: true # Whether to expose metrics at /metrics services: - name: twinnation # Name of your service, can be anything url: https://twinnation.org/health - interval: 15s # Duration to wait between every status check (opt. default: 10s) + interval: 15s # Duration to wait between every status check (default: 10s) conditions: - "[STATUS] == 200" + - "[RESPONSE_TIME] < 300" - name: github url: https://api.github.com/healthz conditions: @@ -31,6 +32,19 @@ services: Note that you can also add environment variables in the your configuration file (i.e. `$DOMAIN`, `${DOMAIN}`) +### Conditions + +Here are some examples of conditions you can use: + +| Condition | Description | Values that would pass | Values that would fail | +| ------------------------------------- | ----------------------------------------- | ---------------------- | ---------------------- | +| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, 500 | +| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, 400, 500 | +| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, 400, 500 | +| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 200, 201, 300, 400 | +| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 1500ms | + + ## Docker Building the Docker image is done as following: diff --git a/config.yaml b/config.yaml index a84f258d..4dc9c095 100644 --- a/config.yaml +++ b/config.yaml @@ -2,9 +2,10 @@ metrics: true services: - name: Twinnation url: https://twinnation.org/health - interval: 30s + interval: 10s conditions: - "[STATUS] == 200" + - "[RESPONSE_TIME] < 20" - name: GitHub API url: https://api.github.com/healthz interval: 30s diff --git a/core/types.go b/core/types.go index b5c39468..32dac9b9 100644 --- a/core/types.go +++ b/core/types.go @@ -10,6 +10,12 @@ import ( "time" ) +const ( + StatusPlaceholder = "[STATUS]" + IPPlaceHolder = "[IP]" + ResponseTimePlaceHolder = "[RESPONSE_TIME]" +) + type HealthStatus struct { Status string `json:"status"` Message string `json:"message,omitempty"` @@ -95,44 +101,31 @@ type Condition string func (c *Condition) evaluate(result *Result) bool { condition := string(*c) + success := false if strings.Contains(condition, "==") { parts := sanitizeAndResolve(strings.Split(condition, "=="), result) - if parts[0] == parts[1] { - result.ConditionResults = append(result.ConditionResults, &ConditionResult{ - Condition: c, - Success: true, - Explanation: fmt.Sprintf("%s is equal to %s", parts[0], parts[1]), - }) - return true - } else { - result.ConditionResults = append(result.ConditionResults, &ConditionResult{ - Condition: c, - Success: false, - Explanation: fmt.Sprintf("%s is not equal to %s", parts[0], parts[1]), - }) - return false - } + success = parts[0] == parts[1] } else if strings.Contains(condition, "!=") { parts := sanitizeAndResolve(strings.Split(condition, "!="), result) - if parts[0] != parts[1] { - result.ConditionResults = append(result.ConditionResults, &ConditionResult{ - Condition: c, - Success: true, - Explanation: fmt.Sprintf("%s is not equal to %s", parts[0], parts[1]), - }) - return true - } else { - result.ConditionResults = append(result.ConditionResults, &ConditionResult{ - Condition: c, - Success: false, - Explanation: fmt.Sprintf("%s is equal to %s", parts[0], parts[1]), - }) - return false - } + success = parts[0] != parts[1] + } else if strings.Contains(condition, "<=") { + parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result) + success = parts[0] <= parts[1] + } else if strings.Contains(condition, ">=") { + parts := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result) + success = parts[0] >= parts[1] + } else if strings.Contains(condition, ">") { + parts := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result) + success = parts[0] > parts[1] + } else if strings.Contains(condition, "<") { + parts := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result) + success = parts[0] < parts[1] } else { result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition)) return false } + result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: c, Success: success}) + return success } func sanitizeAndResolve(list []string, result *Result) []string { @@ -140,13 +133,29 @@ func sanitizeAndResolve(list []string, result *Result) []string { for _, element := range list { element = strings.TrimSpace(element) switch strings.ToUpper(element) { - case "[STATUS]": + case StatusPlaceholder: element = strconv.Itoa(result.HttpStatus) - case "[IP]": + case IPPlaceHolder: element = result.Ip + case ResponseTimePlaceHolder: + element = strconv.Itoa(int(result.Duration.Milliseconds())) default: } sanitizedList = append(sanitizedList, element) } return sanitizedList } + +func sanitizeAndResolveNumerical(list []string, result *Result) []int { + var sanitizedNumbers []int + sanitizedList := sanitizeAndResolve(list, result) + for _, element := range sanitizedList { + if number, err := strconv.Atoi(element); err != nil { + // Default to 0 if the string couldn't be converted to an integer + sanitizedNumbers = append(sanitizedNumbers, 0) + } else { + sanitizedNumbers = append(sanitizedNumbers, number) + } + } + return sanitizedNumbers +} diff --git a/core/types_test.go b/core/types_test.go index 4a55dde2..860f244b 100644 --- a/core/types_test.go +++ b/core/types_test.go @@ -2,6 +2,7 @@ package core import ( "testing" + "time" ) func TestEvaluateWithIp(t *testing.T) { @@ -22,7 +23,7 @@ func TestEvaluateWithStatus(t *testing.T) { } } -func TestEvaluateWithFailure(t *testing.T) { +func TestEvaluateWithStatusFailure(t *testing.T) { condition := Condition("[STATUS] == 200") result := &Result{HttpStatus: 500} condition.evaluate(result) @@ -31,6 +32,60 @@ func TestEvaluateWithFailure(t *testing.T) { } } +func TestEvaluateWithStatusUsingLessThan(t *testing.T) { + condition := Condition("[STATUS] < 300") + result := &Result{HttpStatus: 201} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithStatusFailureUsingLessThan(t *testing.T) { + condition := Condition("[STATUS] < 300") + result := &Result{HttpStatus: 404} + condition.evaluate(result) + if result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a failure", condition) + } +} + +func TestEvaluateWithResponseTimeUsingLessThan(t *testing.T) { + condition := Condition("[RESPONSE_TIME] < 500") + result := &Result{Duration: time.Millisecond * 50} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithResponseTimeUsingGreaterThan(t *testing.T) { + condition := Condition("[RESPONSE_TIME] > 500") + result := &Result{Duration: time.Millisecond * 750} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) { + condition := Condition("[RESPONSE_TIME] >= 500") + result := &Result{Duration: time.Millisecond * 500} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) { + condition := Condition("[RESPONSE_TIME] <= 500") + result := &Result{Duration: time.Millisecond * 500} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + func TestIntegrationEvaluateConditions(t *testing.T) { condition := Condition("[STATUS] == 200") service := Service{