Compare commits

..

38 Commits

Author SHA1 Message Date
TwinProduction
2207dd9c32 Fix test 2021-01-21 16:34:40 -05:00
TwinProduction
3204a79eb6 Lazily retry triggered alerts in case of failure 2021-01-21 16:14:32 -05:00
TwinProduction
e9ac115a95 Replace ✔️ by 2021-01-20 17:47:21 -05:00
TwinProduction
c90c786f39 Tweak build action 2021-01-19 00:01:55 -05:00
TwinProduction
f10e2ac639 Tweak build action 2021-01-18 23:55:41 -05:00
TwinProduction
c2d899f2a3 Tweak build action 2021-01-18 23:52:48 -05:00
TwinProduction
7415d8e361 Tweak build action 2021-01-18 23:47:17 -05:00
TwinProduction
298dcc4790 Tweak build action 2021-01-18 23:43:02 -05:00
TwinProduction
2f2890c093 Tweak build action 2021-01-18 23:38:23 -05:00
TwinProduction
e463aec5f6 Tweak build action 2021-01-18 23:34:16 -05:00
TwinProduction
6b3e11a47c Minor update 2021-01-18 23:28:33 -05:00
TwinProduction
0985e3bed8 Minor update 2021-01-17 16:38:22 -05:00
TwinProduction
6d8fd267de Fix mattermost docker-compose example 2021-01-16 20:36:59 -05:00
TwinProduction
e89bb932ea Fix pattern issue 2021-01-15 20:11:43 -05:00
TwinProduction
77737dbab6 Add TestCondition_evaluateWithBodyHTMLPattern 2021-01-15 19:45:17 -05:00
TwinProduction
271c3dc91d Performance improvements 2021-01-14 22:49:48 -05:00
TwinProduction
5860a27ab5 Improve existing tests 2021-01-14 22:49:19 -05:00
TwinProduction
819093cb7e Implement any function and prettify displayed condition on failure 2021-01-14 20:08:27 -05:00
TwinProduction
855c106e9b Reduce ping timeout during test 2021-01-12 22:19:19 -05:00
TwinProduction
04de262268 Add comment for pingTimeout 2021-01-12 21:37:21 -05:00
TwinProduction
26d8870cab Improve test coverage 2021-01-12 21:26:28 -05:00
TwinProduction
aec867ae69 Fix #72: Connected placeholder shouldn't resolve to true when when host is unreachable 2021-01-12 21:08:18 -05:00
TwinProduction
a515335c15 Improve test coverage 2021-01-10 01:32:50 -05:00
TwinProduction
96dd9809f4 Disable patch status 2021-01-10 01:27:20 -05:00
TwinProduction
20b4c86023 Improve test coverage 2021-01-10 01:22:27 -05:00
TwinProduction
6f8a728c5f Improve test coverage 2021-01-10 00:24:31 -05:00
TwinProduction
1669f91a2d Improve test coverage 2021-01-09 23:52:11 -05:00
TwinProduction
3d265afa37 Minor update 2021-01-09 23:09:35 -05:00
TwinProduction
aa050e9292 Add codecov.yml 2021-01-09 23:06:30 -05:00
TwinProduction
91a9fa5274 Improve testing coverage 2021-01-09 22:55:36 -05:00
TwinProduction
150e33a1c7 Update comment 2021-01-09 21:14:54 -05:00
TwinProduction
da9a6282c7 Fix test 2021-01-09 01:17:16 -05:00
TwinProduction
907b611505 Use a fixed date instead 2021-01-08 23:40:43 -05:00
TwinProduction
eaf205eded Minor fix 2021-01-08 22:56:13 -05:00
TwinProduction
329bd86e09 Replace GetAll by GetAllAsJSON and change storage package implementation 2021-01-08 22:41:57 -05:00
TwinProduction
19bb831fbf Update test to prevent this from happening again 2021-01-08 17:43:45 -05:00
TwinProduction
bca38bd372 Fix #70: Success always showing as true 2021-01-08 17:25:55 -05:00
TwinProduction
9095649afb Minor update 2021-01-04 23:38:47 -05:00
29 changed files with 2093 additions and 1397 deletions

6
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
ignore:
- "watchdog/watchdog.go"
coverage:
status:
patch: off

View File

@@ -15,16 +15,18 @@ jobs:
timeout-minutes: 5
steps:
- name: Set up Go 1.15
uses: actions/setup-go@v1
uses: actions/setup-go@v2
with:
go-version: 1.15
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Build binary to make sure it works
run: go build -mod vendor
- name: Test
run: sudo go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the "PATH=$PATH", we need it to use the same "go" executable that was configured by the "Set
# up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo "PATH=$PATH" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v1.0.14
with:

1485
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,11 @@ package custom
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"strings"
"github.com/TwinProduction/gatus/client"
@@ -74,6 +76,12 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
// Send a request to the alert provider and return the body
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
return nil, errors.New("error")
}
return []byte("{}"), nil
}
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
if err != nil {

View File

@@ -31,7 +31,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":heavy_check_mark:"
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}

View File

@@ -12,6 +12,10 @@ import (
var (
secureHTTPClient *http.Client
insecureHTTPClient *http.Client
// pingTimeout is the timeout for the Ping function
// This is mainly exposed for testing purposes
pingTimeout = 5 * time.Second
)
// GetHTTPClient returns the shared HTTP client
@@ -19,7 +23,7 @@ func GetHTTPClient(insecure bool) *http.Client {
if insecure {
if insecureHTTPClient == nil {
insecureHTTPClient = &http.Client{
Timeout: time.Second * 10,
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
@@ -62,7 +66,7 @@ func Ping(address string) (bool, time.Duration) {
return false, 0
}
pinger.Count = 1
pinger.Timeout = 5 * time.Second
pinger.Timeout = pingTimeout
pinger.SetNetwork("ip4")
pinger.SetPrivileged(true)
err = pinger.Run()
@@ -70,6 +74,10 @@ func Ping(address string) (bool, time.Duration) {
return false, 0
}
if pinger.Statistics() != nil {
// If the packet loss is 100, it means that the packet didn't reach the host
if pinger.Statistics().PacketLoss == 100 {
return false, pinger.Timeout
}
return true, pinger.Statistics().MaxRtt
}
return true, 0

View File

@@ -2,6 +2,7 @@ package client
import (
"testing"
"time"
)
func TestGetHTTPClient(t *testing.T) {
@@ -28,6 +29,7 @@ func TestGetHTTPClient(t *testing.T) {
}
func TestPing(t *testing.T) {
pingTimeout = 500 * time.Millisecond
if success, rtt := Ping("127.0.0.1"); !success {
t.Error("expected true")
if rtt == 0 {
@@ -35,7 +37,13 @@ func TestPing(t *testing.T) {
}
}
if success, rtt := Ping("256.256.256.256"); success {
t.Error("expected false")
t.Error("expected false, because the IP is invalid")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("192.168.152.153"); success {
t.Error("expected false, because the IP is valid but the host should be unreachable")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}

View File

@@ -86,6 +86,12 @@ func Get() *Config {
return config
}
// Set sets the configuration
// Used only for testing
func Set(cfg *Config) {
config = cfg
}
// Load loads a custom configuration file
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
func Load(configFile string) error {

View File

@@ -17,6 +17,16 @@ func TestGetBeforeConfigIsLoaded(t *testing.T) {
t.Fatal("Should've panicked because the configuration hasn't been loaded yet")
}
func TestSet(t *testing.T) {
if config != nil {
t.Fatal("config should've been nil")
}
Set(&Config{})
if config == nil {
t.Fatal("config shouldn't have been nil")
}
}
func TestLoadFileThatDoesNotExist(t *testing.T) {
err := Load("file-that-does-not-exist.yaml")
if err == nil {
@@ -155,7 +165,7 @@ web:
port: 12345
services:
- name: twinnation
url: https://twinnation.org/actuator/health
url: https://twinnation.org/health
conditions:
- "[STATUS] == 200"
`))
@@ -168,17 +178,15 @@ services:
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Services[0].URL != "https://twinnation.org/actuator/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/actuator/health")
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if config.Web.Address != DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", DefaultAddress)
}
if config.Web.Port != 12345 {
t.Errorf("Port should have been %d, because it is specified in config", 12345)
}

View File

@@ -26,6 +26,12 @@ type Alert struct {
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
// should be set back to false. It is used to prevent the same alert from going out twice.
//
// This value should only be modified if the provider.AlertProvider's Send function does not return an error for an
// alert that hasn't been triggered yet. This doubles as a lazy retry. The reason why this behavior isn't also
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
// some reason, the alert provider always returns errors when trying to send the resolved notification
// (SendOnResolved).
Triggered bool
}

View File

@@ -2,7 +2,6 @@ package core
import (
"fmt"
"log"
"strconv"
"strings"
"time"
@@ -48,11 +47,20 @@ const (
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
// LengthFunctionPrefix is the prefix for the length function
//
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
LengthFunctionPrefix = "len("
// PatternFunctionPrefix is the prefix for the pattern function
//
// Usage: pat(192.168.*.*)
PatternFunctionPrefix = "pat("
// AnyFunctionPrefix is the prefix for the any function
//
// Usage: any(1.1.1.1, 1.0.0.1)
AnyFunctionPrefix = "any("
// FunctionSuffix is the suffix for all functions
FunctionSuffix = ")"
@@ -64,51 +72,52 @@ const (
type Condition string
// evaluate the Condition with the Result of the health check
func (c *Condition) evaluate(result *Result) bool {
condition := string(*c)
func (c Condition) evaluate(result *Result) bool {
condition := string(c)
success := false
var resolvedCondition string
conditionToDisplay := condition
if strings.Contains(condition, "==") {
parts := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = isEqual(parts[0], parts[1])
resolvedCondition = fmt.Sprintf("%v == %v", parts[0], parts[1])
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = isEqual(resolvedParameters[0], resolvedParameters[1])
if !success {
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
}
} else if strings.Contains(condition, "!=") {
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = !isEqual(parts[0], parts[1])
resolvedCondition = fmt.Sprintf("%v != %v", parts[0], parts[1])
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
if !success {
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
}
} else if strings.Contains(condition, "<=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = parts[0] <= parts[1]
resolvedCondition = fmt.Sprintf("%v <= %v", parts[0], parts[1])
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = resolvedParameters[0] <= resolvedParameters[1]
if !success {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
}
} else if strings.Contains(condition, ">=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = parts[0] >= parts[1]
resolvedCondition = fmt.Sprintf("%v >= %v", parts[0], parts[1])
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = resolvedParameters[0] >= resolvedParameters[1]
if !success {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
}
} else if strings.Contains(condition, ">") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = parts[0] > parts[1]
resolvedCondition = fmt.Sprintf("%v > %v", parts[0], parts[1])
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = resolvedParameters[0] > resolvedParameters[1]
if !success {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
}
} else if strings.Contains(condition, "<") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = parts[0] < parts[1]
resolvedCondition = fmt.Sprintf("%v < %v", parts[0], parts[1])
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = resolvedParameters[0] < resolvedParameters[1]
if !success {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
}
} else {
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
return false
}
conditionToDisplay := condition
// If the condition isn't a success, return what the resolved condition was too
if !success {
log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, resolvedCondition)
// Check if the resolved condition was an invalid path
isResolvedConditionInvalidPath := strings.ReplaceAll(resolvedCondition, fmt.Sprintf("%s ", InvalidConditionElementSuffix), "") == condition
if isResolvedConditionInvalidPath {
// Since, in the event of an invalid path, the resolvedCondition contains the condition itself,
// we'll only display the resolvedCondition
conditionToDisplay = resolvedCondition
} else {
conditionToDisplay = fmt.Sprintf("%s (%s)", condition, resolvedCondition)
}
//log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success
@@ -116,33 +125,66 @@ func (c *Condition) evaluate(result *Result) bool {
// isEqual compares two strings.
//
// It also supports the pattern function. That is to say, if one of the strings starts with PatternFunctionPrefix
// and ends with FunctionSuffix, it will be treated like a pattern.
// Supports the pattern and the any functions.
// i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like
// a pattern.
func isEqual(first, second string) bool {
var isFirstPattern, isSecondPattern bool
if strings.HasPrefix(first, PatternFunctionPrefix) && strings.HasSuffix(first, FunctionSuffix) {
isFirstPattern = true
first = strings.TrimSuffix(strings.TrimPrefix(first, PatternFunctionPrefix), FunctionSuffix)
}
if strings.HasPrefix(second, PatternFunctionPrefix) && strings.HasSuffix(second, FunctionSuffix) {
isSecondPattern = true
second = strings.TrimSuffix(strings.TrimPrefix(second, PatternFunctionPrefix), FunctionSuffix)
}
if isFirstPattern && !isSecondPattern {
return pattern.Match(first, second)
} else if !isFirstPattern && isSecondPattern {
return pattern.Match(second, first)
} else {
return first == second
firstHasFunctionSuffix := strings.HasSuffix(first, FunctionSuffix)
secondHasFunctionSuffix := strings.HasSuffix(second, FunctionSuffix)
if firstHasFunctionSuffix || secondHasFunctionSuffix {
var isFirstPattern, isSecondPattern bool
if strings.HasPrefix(first, PatternFunctionPrefix) && firstHasFunctionSuffix {
isFirstPattern = true
first = strings.TrimSuffix(strings.TrimPrefix(first, PatternFunctionPrefix), FunctionSuffix)
}
if strings.HasPrefix(second, PatternFunctionPrefix) && secondHasFunctionSuffix {
isSecondPattern = true
second = strings.TrimSuffix(strings.TrimPrefix(second, PatternFunctionPrefix), FunctionSuffix)
}
if isFirstPattern && !isSecondPattern {
return pattern.Match(first, second)
} else if !isFirstPattern && isSecondPattern {
return pattern.Match(second, first)
}
var isFirstAny, isSecondAny bool
if strings.HasPrefix(first, AnyFunctionPrefix) && firstHasFunctionSuffix {
isFirstAny = true
first = strings.TrimSuffix(strings.TrimPrefix(first, AnyFunctionPrefix), FunctionSuffix)
}
if strings.HasPrefix(second, AnyFunctionPrefix) && secondHasFunctionSuffix {
isSecondAny = true
second = strings.TrimSuffix(strings.TrimPrefix(second, AnyFunctionPrefix), FunctionSuffix)
}
if isFirstAny && !isSecondAny {
options := strings.Split(first, ",")
for _, option := range options {
if strings.TrimSpace(option) == second {
return true
}
}
return false
} else if !isFirstAny && isSecondAny {
options := strings.Split(second, ",")
for _, option := range options {
if strings.TrimSpace(option) == first {
return true
}
}
return false
}
}
return first == second
}
// sanitizeAndResolve sanitizes and resolves a list of element and returns the list of resolved elements
func sanitizeAndResolve(list []string, result *Result) []string {
var sanitizedList []string
// sanitizeAndResolve sanitizes and resolves a list of elements and returns the list of parameters as well as a list
// of resolved parameters
func sanitizeAndResolve(elements []string, result *Result) ([]string, []string) {
parameters := make([]string, len(elements))
resolvedParameters := make([]string, len(elements))
body := strings.TrimSpace(string(result.Body))
for _, element := range list {
for i, element := range elements {
element = strings.TrimSpace(element)
parameters[i] = element
switch strings.ToUpper(element) {
case StatusPlaceholder:
element = strconv.Itoa(result.HTTPStatus)
@@ -166,42 +208,68 @@ func sanitizeAndResolve(list []string, result *Result) []string {
wantLength = true
element = strings.TrimSuffix(strings.TrimPrefix(element, LengthFunctionPrefix), FunctionSuffix)
}
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.Replace(element, fmt.Sprintf("%s.", BodyPlaceholder), "", 1), result.Body)
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(element, BodyPlaceholder+"."), result.Body)
if err != nil {
if err.Error() != "unexpected end of JSON input" {
result.Errors = append(result.Errors, err.Error())
}
if wantLength {
element = fmt.Sprintf("%s%s%s %s", LengthFunctionPrefix, element, FunctionSuffix, InvalidConditionElementSuffix)
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix
} else {
element = fmt.Sprintf("%s %s", element, InvalidConditionElementSuffix)
element = element + " " + InvalidConditionElementSuffix
}
} else {
if wantLength {
element = fmt.Sprintf("%d", resolvedElementLength)
element = strconv.Itoa(resolvedElementLength)
} else {
element = resolvedElement
}
}
}
}
sanitizedList = append(sanitizedList, element)
resolvedParameters[i] = element
}
return sanitizedList
return parameters, resolvedParameters
}
func sanitizeAndResolveNumerical(list []string, result *Result) []int64 {
var sanitizedNumbers []int64
sanitizedList := sanitizeAndResolve(list, result)
for _, element := range sanitizedList {
func sanitizeAndResolveNumerical(list []string, result *Result) (parameters []string, resolvedNumericalParameters []int64) {
parameters, resolvedParameters := sanitizeAndResolve(list, result)
for _, element := range resolvedParameters {
if duration, err := time.ParseDuration(element); duration != 0 && err == nil {
sanitizedNumbers = append(sanitizedNumbers, duration.Milliseconds())
resolvedNumericalParameters = append(resolvedNumericalParameters, duration.Milliseconds())
} else if number, err := strconv.ParseInt(element, 10, 64); err != nil {
// Default to 0 if the string couldn't be converted to an integer
sanitizedNumbers = append(sanitizedNumbers, 0)
resolvedNumericalParameters = append(resolvedNumericalParameters, 0)
} else {
sanitizedNumbers = append(sanitizedNumbers, number)
resolvedNumericalParameters = append(resolvedNumericalParameters, number)
}
}
return sanitizedNumbers
return parameters, resolvedNumericalParameters
}
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
}
// XXX: make this configurable? i.e. show-resolved-conditions-on-failure
func prettify(parameters []string, resolvedParameters []string, operator string) string {
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
// we'll return the resolvedParameters as-is.
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
}
// First element is a placeholder
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
}
// Second element is a placeholder
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
}
// Both elements are placeholders...?
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
}
// Neither elements are placeholders
return parameters[0] + " " + operator + " " + parameters[1]
}

View File

@@ -0,0 +1,75 @@
package core
import "testing"
func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 200}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 400}
condition.evaluate(result)
}
b.ReportAllocs()
}

View File

@@ -1,6 +1,7 @@
package core
import (
"fmt"
"strconv"
"testing"
"time"
@@ -22,6 +23,9 @@ func TestCondition_evaluateWithStatus(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
if result.ConditionResults[0].Condition != string(condition) {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, condition, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithStatusFailure(t *testing.T) {
@@ -31,6 +35,10 @@ func TestCondition_evaluateWithStatusFailure(t *testing.T) {
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[STATUS] (500) == 200"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithStatusUsingLessThan(t *testing.T) {
@@ -40,6 +48,10 @@ func TestCondition_evaluateWithStatusUsingLessThan(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[STATUS] < 300"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithStatusFailureUsingLessThan(t *testing.T) {
@@ -49,6 +61,10 @@ func TestCondition_evaluateWithStatusFailureUsingLessThan(t *testing.T) {
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[STATUS] (404) < 300"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithResponseTimeUsingLessThan(t *testing.T) {
@@ -58,6 +74,10 @@ func TestCondition_evaluateWithResponseTimeUsingLessThan(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[RESPONSE_TIME] < 500"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithResponseTimeUsingLessThanDuration(t *testing.T) {
@@ -67,6 +87,10 @@ func TestCondition_evaluateWithResponseTimeUsingLessThanDuration(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[RESPONSE_TIME] < 1s"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithResponseTimeUsingLessThanInvalid(t *testing.T) {
@@ -76,6 +100,10 @@ func TestCondition_evaluateWithResponseTimeUsingLessThanInvalid(t *testing.T) {
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have failed because the condition has an invalid numerical value that should've automatically resolved to 0", condition)
}
expectedConditionDisplayed := "[RESPONSE_TIME] (50) < potato (0)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
@@ -87,6 +115,10 @@ func TestCondition_evaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[RESPONSE_TIME] > 500"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithResponseTimeUsingGreaterThanDuration(t *testing.T) {
@@ -96,6 +128,10 @@ func TestCondition_evaluateWithResponseTimeUsingGreaterThanDuration(t *testing.T
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[RESPONSE_TIME] > 1s"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
@@ -105,6 +141,10 @@ func TestCondition_evaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[RESPONSE_TIME] >= 500"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
@@ -114,6 +154,10 @@ func TestCondition_evaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[RESPONSE_TIME] <= 500"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBody(t *testing.T) {
@@ -123,6 +167,10 @@ func TestCondition_evaluateWithBody(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY] == test"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPath(t *testing.T) {
@@ -132,6 +180,10 @@ func TestCondition_evaluateWithBodyJSONPath(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].status == UP"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathComplex(t *testing.T) {
@@ -141,31 +193,35 @@ func TestCondition_evaluateWithBodyJSONPathComplex(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].data.name == john"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithInvalidBodyJSONPathComplex(t *testing.T) {
expectedResolvedCondition := "[BODY].data.name (INVALID) == john"
condition := Condition("[BODY].data.name == john")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure, because the path was invalid", condition)
}
if result.ConditionResults[0].Condition != expectedResolvedCondition {
t.Errorf("Condition '%s' should have resolved to '%s', but resolved to '%s' instead", condition, expectedResolvedCondition, result.ConditionResults[0].Condition)
expectedConditionDisplayed := "[BODY].data.name (INVALID) == john"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithInvalidBodyJSONPathComplexWithLengthFunction(t *testing.T) {
expectedResolvedCondition := "len([BODY].data.name) (INVALID) == john"
condition := Condition("len([BODY].data.name) == john")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure, because the path was invalid", condition)
}
if result.ConditionResults[0].Condition != expectedResolvedCondition {
t.Errorf("Condition '%s' should have resolved to '%s', but resolved to '%s' instead", condition, expectedResolvedCondition, result.ConditionResults[0].Condition)
expectedConditionDisplayed := "len([BODY].data.name) (INVALID) == john"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
@@ -176,6 +232,10 @@ func TestCondition_evaluateWithBodyJSONPathDoublePlaceholders(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].user.firstName != [BODY].user.lastName"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathDoublePlaceholdersFailure(t *testing.T) {
@@ -185,6 +245,10 @@ func TestCondition_evaluateWithBodyJSONPathDoublePlaceholdersFailure(t *testing.
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[BODY].user.firstName (john) == [BODY].user.lastName (doe)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathLongInt(t *testing.T) {
@@ -194,6 +258,10 @@ func TestCondition_evaluateWithBodyJSONPathLongInt(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].data.id == 1"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathComplexInt(t *testing.T) {
@@ -203,6 +271,10 @@ func TestCondition_evaluateWithBodyJSONPathComplexInt(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].data[1].id == 2"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathComplexIntUsingGreaterThan(t *testing.T) {
@@ -212,6 +284,10 @@ func TestCondition_evaluateWithBodyJSONPathComplexIntUsingGreaterThan(t *testing
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].data.id > 0"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathComplexIntFailureUsingGreaterThan(t *testing.T) {
@@ -221,6 +297,10 @@ func TestCondition_evaluateWithBodyJSONPathComplexIntFailureUsingGreaterThan(t *
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[BODY].data.id (1) > 5"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathComplexIntUsingLessThan(t *testing.T) {
@@ -230,6 +310,10 @@ func TestCondition_evaluateWithBodyJSONPathComplexIntUsingLessThan(t *testing.T)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].data.id < 5"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyJSONPathComplexIntFailureUsingLessThan(t *testing.T) {
@@ -239,6 +323,10 @@ func TestCondition_evaluateWithBodyJSONPathComplexIntFailureUsingLessThan(t *tes
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[BODY].data.id (10) < 5"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodySliceLength(t *testing.T) {
@@ -248,6 +336,10 @@ func TestCondition_evaluateWithBodySliceLength(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "len([BODY].data) == 3"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyStringLength(t *testing.T) {
@@ -257,6 +349,36 @@ func TestCondition_evaluateWithBodyStringLength(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "len([BODY].name) == 8"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyPattern(t *testing.T) {
condition := Condition("[BODY] == pat(*john*)")
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY] == pat(*john*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithReverseBodyPattern(t *testing.T) {
condition := Condition("pat(*john*) == [BODY]")
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "pat(*john*) == [BODY]"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyStringPattern(t *testing.T) {
@@ -266,6 +388,24 @@ func TestCondition_evaluateWithBodyStringPattern(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY].name == pat(*ohn*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyHTMLPattern(t *testing.T) {
var html = `<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">john.doe</div></body></html>`
condition := Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)")
result := &Result{Body: []byte(html)}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[BODY] == pat(*<div id=\"user\">john.doe</div>*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyStringPatternFailure(t *testing.T) {
@@ -275,14 +415,9 @@ func TestCondition_evaluateWithBodyStringPatternFailure(t *testing.T) {
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestCondition_evaluateWithBodyPatternFailure(t *testing.T) {
condition := Condition("[BODY] == pat(*john*)")
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
expectedConditionDisplayed := "[BODY].name (john.doe) == pat(bob*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
@@ -293,6 +428,10 @@ func TestCondition_evaluateWithIPPattern(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[IP] == pat(10.*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithIPPatternFailure(t *testing.T) {
@@ -302,6 +441,10 @@ func TestCondition_evaluateWithIPPatternFailure(t *testing.T) {
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[IP] (255.255.255.255) == pat(10.*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithStatusPattern(t *testing.T) {
@@ -311,6 +454,10 @@ func TestCondition_evaluateWithStatusPattern(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[STATUS] == pat(4*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithStatusPatternFailure(t *testing.T) {
@@ -320,6 +467,105 @@ func TestCondition_evaluateWithStatusPatternFailure(t *testing.T) {
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[STATUS] (404) != pat(4*)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithBodyStringAny(t *testing.T) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
expectedConditionDisplayed := "[BODY].name == any(john.doe, jane.doe)"
results := []*Result{
{Body: []byte("{\"name\": \"john.doe\"}")},
{Body: []byte("{\"name\": \"jane.doe\"}")},
}
for _, result := range results {
success := condition.evaluate(result)
if !success || !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
}
func TestCondition_evaluateWithBodyStringAnyFailure(t *testing.T) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[BODY].name (bob.doe) == any(john.doe, jane.doe)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithStatusAny(t *testing.T) {
condition := Condition("[STATUS] == any(200, 429)")
statuses := []int{200, 429}
for _, status := range statuses {
result := &Result{HTTPStatus: status}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[STATUS] == any(200, 429)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
}
func TestCondition_evaluateWithReverseStatusAny(t *testing.T) {
condition := Condition("any(200, 429) == [STATUS]")
statuses := []int{200, 429}
for _, status := range statuses {
result := &Result{HTTPStatus: status}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "any(200, 429) == [STATUS]"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
}
func TestCondition_evaluateWithStatusAnyFailure(t *testing.T) {
condition := Condition("[STATUS] == any(200, 429)")
statuses := []int{201, 400, 404, 500}
for _, status := range statuses {
result := &Result{HTTPStatus: status}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := fmt.Sprintf("[STATUS] (%d) == any(200, 429)", status)
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
}
func TestCondition_evaluateWithReverseStatusAnyFailure(t *testing.T) {
condition := Condition("any(200, 429) == [STATUS]")
statuses := []int{201, 400, 404, 500}
for _, status := range statuses {
result := &Result{HTTPStatus: status}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := fmt.Sprintf("any(200, 429) == [STATUS] (%d)", status)
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
}
func TestCondition_evaluateWithConnected(t *testing.T) {
@@ -329,6 +575,10 @@ func TestCondition_evaluateWithConnected(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[CONNECTED] == true"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithConnectedFailure(t *testing.T) {
@@ -338,6 +588,10 @@ func TestCondition_evaluateWithConnectedFailure(t *testing.T) {
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[CONNECTED] (false) == true"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithUnsetCertificateExpiration(t *testing.T) {
@@ -347,6 +601,10 @@ func TestCondition_evaluateWithUnsetCertificateExpiration(t *testing.T) {
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[CERTIFICATE_EXPIRATION] == 0"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithCertificateExpirationGreaterThanNumerical(t *testing.T) {
@@ -357,6 +615,10 @@ func TestCondition_evaluateWithCertificateExpirationGreaterThanNumerical(t *test
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[CERTIFICATE_EXPIRATION] > 2419200000"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithCertificateExpirationGreaterThanNumericalFailure(t *testing.T) {
@@ -367,6 +629,10 @@ func TestCondition_evaluateWithCertificateExpirationGreaterThanNumericalFailure(
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithCertificateExpirationGreaterThanDuration(t *testing.T) {
@@ -376,6 +642,10 @@ func TestCondition_evaluateWithCertificateExpirationGreaterThanDuration(t *testi
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
expectedConditionDisplayed := "[CERTIFICATE_EXPIRATION] > 12h"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}
func TestCondition_evaluateWithCertificateExpirationGreaterThanDurationFailure(t *testing.T) {
@@ -385,4 +655,8 @@ func TestCondition_evaluateWithCertificateExpirationGreaterThanDurationFailure(t
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
expectedConditionDisplayed := "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)"
if result.ConditionResults[0].Condition != expectedConditionDisplayed {
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", condition, expectedConditionDisplayed, result.ConditionResults[0].Condition)
}
}

View File

@@ -1,6 +1,16 @@
alerting:
mattermost:
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
insecure: true
services:
- name: example
url: http://example.org
interval: 30s
interval: 1m
alerts:
- type: mattermost
enabled: true
description: "healthcheck failed 3 times in a row"
send-on-resolved: true
conditions:
- "[STATUS] == 200"

View File

@@ -1,13 +1,24 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
ports:
- 8080:8080
volumes:
- ./config.yaml:/config/config.yaml
mattermost-preview:
image: mattermost/mattermost-preview:latest
ports:
- 8065:8065
services:
gatus:
container_name: gatus
image: twinproduction/gatus:latest
ports:
- 8080:8080
volumes:
- ./config.yaml:/config/config.yaml
networks:
- default
mattermost:
container_name: mattermost
image: mattermost/mattermost-preview:5.26.0
ports:
- 8065:8065
networks:
- default
networks:
default:
driver: bridge

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.15
require (
cloud.google.com/go v0.74.0 // indirect
github.com/TwinProduction/gocache v0.3.0
github.com/TwinProduction/gocache v1.1.0
github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663
github.com/google/gofuzz v1.2.0 // indirect
github.com/gorilla/mux v1.8.0

4
go.sum
View File

@@ -50,8 +50,8 @@ github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/TwinProduction/gocache v0.3.0 h1:nC02PSOyGLiXGrOJ6eskGc5chBq5GW6m3pA2g341VEM=
github.com/TwinProduction/gocache v0.3.0/go.mod h1:+qH57V/K4oAcX9C7CvgJTwUX4lzfIUXQC/6XaRSOS1Y=
github.com/TwinProduction/gocache v1.1.0 h1:mibBUyccd8kGHlm5dXhTMDOvWBK4mjNqGyOOkG8mib8=
github.com/TwinProduction/gocache v1.1.0/go.mod h1:+qH57V/K4oAcX9C7CvgJTwUX4lzfIUXQC/6XaRSOS1Y=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=

View File

@@ -10,10 +10,11 @@ func Match(pattern, s string) bool {
if pattern == "*" {
return true
}
// Backslashes break filepath.Match, so we'll remove all of them.
// This has a pretty significant impact on performance when there
// are backslashes, but at least it doesn't break filepath.Match.
s = strings.ReplaceAll(s, "\\", "")
// Separators found in the string break filepath.Match, so we'll remove all of them.
// This has a pretty significant impact on performance when there are separators in
// the strings, but at least it doesn't break filepath.Match.
s = strings.ReplaceAll(s, string(filepath.Separator), "")
pattern = strings.ReplaceAll(pattern, string(filepath.Separator), "")
matched, _ := filepath.Match(pattern, s)
return matched
}

View File

@@ -1,6 +1,7 @@
package storage
import (
"encoding/json"
"fmt"
"sync"
@@ -22,19 +23,12 @@ func NewInMemoryStore() *InMemoryStore {
}
}
// GetAll returns all the observed results for all services from the in memory store
func (ims *InMemoryStore) GetAll() map[string]*core.ServiceStatus {
results := make(map[string]*core.ServiceStatus, len(ims.serviceStatuses))
// GetAllAsJSON returns the JSON encoding of all monitored core.ServiceStatus
func (ims *InMemoryStore) GetAllAsJSON() ([]byte, error) {
ims.serviceResultsMutex.RLock()
for key, serviceStatus := range ims.serviceStatuses {
results[key] = &core.ServiceStatus{
Name: serviceStatus.Name,
Group: serviceStatus.Group,
Results: copyResults(serviceStatus.Results),
}
}
serviceStatuses, err := json.Marshal(ims.serviceStatuses)
ims.serviceResultsMutex.RUnlock()
return results
return serviceStatuses, err
}
// GetServiceStatus returns the service status for a given service name in the given group
@@ -59,46 +53,6 @@ func (ims *InMemoryStore) Insert(service *core.Service, result *core.Result) {
ims.serviceResultsMutex.Unlock()
}
func copyResults(results []*core.Result) []*core.Result {
var copiedResults []*core.Result
for _, result := range results {
copiedResults = append(copiedResults, &core.Result{
HTTPStatus: result.HTTPStatus,
DNSRCode: result.DNSRCode,
Body: result.Body,
Hostname: result.Hostname,
IP: result.IP,
Connected: result.Connected,
Duration: result.Duration,
Errors: copyErrors(result.Errors),
ConditionResults: copyConditionResults(result.ConditionResults),
Success: result.Connected,
Timestamp: result.Timestamp,
CertificateExpiration: result.CertificateExpiration,
})
}
return copiedResults
}
func copyConditionResults(conditionResults []*core.ConditionResult) []*core.ConditionResult {
var copiedConditionResults []*core.ConditionResult
for _, conditionResult := range conditionResults {
copiedConditionResults = append(copiedConditionResults, &core.ConditionResult{
Condition: conditionResult.Condition,
Success: conditionResult.Success,
})
}
return copiedConditionResults
}
func copyErrors(errors []string) []string {
var copiedErrors []string
for _, err := range errors {
copiedErrors = append(copiedErrors, err)
}
return copiedErrors
}
// Clear will empty all the results from the in memory store
func (ims *InMemoryStore) Clear() {
ims.serviceResultsMutex.Lock()

View File

@@ -8,131 +8,98 @@ import (
"github.com/TwinProduction/gatus/core"
)
var testService = core.Service{
Name: "Name",
Group: "Group",
URL: "URL",
DNS: &core.DNS{QueryType: "QueryType", QueryName: "QueryName"},
Method: "Method",
Body: "Body",
GraphQL: false,
Headers: nil,
Interval: time.Second * 2,
Conditions: nil,
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
var memoryStore = NewInMemoryStore()
timestamp = time.Now()
func TestStorage_GetAllFromEmptyMemoryStoreReturnsNothing(t *testing.T) {
memoryStore.Clear()
results := memoryStore.GetAll()
if len(results) != 0 {
t.Errorf("MemoryStore should've returned 0 results, but actually returned %d", len(results))
testService = core.Service{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
}
func TestStorage_InsertIntoEmptyMemoryStoreThenGetAllReturnsOneResult(t *testing.T) {
memoryStore.Clear()
result := core.Result{
testSuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
DNSRCode: "DNSRCode",
Body: nil,
Hostname: "Hostname",
IP: "IP",
Connected: false,
Duration: time.Second * 2,
Body: []byte("body"),
Errors: nil,
ConditionResults: nil,
Success: false,
Timestamp: time.Now(),
CertificateExpiration: time.Second * 2,
}
memoryStore.Insert(&testService, &result)
results := memoryStore.GetAll()
if len(results) != 1 {
t.Errorf("MemoryStore should've returned 0 results, but actually returned %d", len(results))
}
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
storedResult, exists := results[key]
if !exists {
t.Fatalf("In Memory Store should've contained key '%s', but didn't", key)
}
if storedResult.Name != testService.Name {
t.Errorf("Stored Results Name should've been %s, but was %s", testService.Name, storedResult.Name)
}
if storedResult.Group != testService.Group {
t.Errorf("Stored Results Group should've been %s, but was %s", testService.Group, storedResult.Group)
}
if len(storedResult.Results) != 1 {
t.Errorf("Stored Results for service %s should've had 1 result, but actually had %d", storedResult.Name, len(storedResult.Results))
}
if storedResult.Results[0] == &result {
t.Errorf("Returned result is the same reference as result passed to insert. Returned result should be copies only")
}
}
func TestStorage_InsertTwoResultsForSingleServiceIntoEmptyMemoryStore_ThenGetAllReturnsTwoResults(t *testing.T) {
memoryStore.Clear()
result1 := core.Result{
HTTPStatus: 404,
DNSRCode: "DNSRCode",
Body: nil,
Hostname: "Hostname",
IP: "IP",
Connected: false,
Duration: time.Second * 2,
Errors: nil,
ConditionResults: nil,
Success: false,
Timestamp: time.Now(),
CertificateExpiration: time.Second * 2,
}
result2 := core.Result{
HTTPStatus: 200,
DNSRCode: "DNSRCode",
Body: nil,
Hostname: "Hostname",
IP: "IP",
Connected: true,
Duration: time.Second * 2,
Errors: nil,
ConditionResults: nil,
Success: true,
Timestamp: time.Now(),
CertificateExpiration: time.Second * 2,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
resultsToInsert := []core.Result{result1, result2}
memoryStore.Insert(&testService, &result1)
memoryStore.Insert(&testService, &result2)
results := memoryStore.GetAll()
if len(results) != 1 {
t.Fatalf("MemoryStore should've returned 1 results, but actually returned %d", len(results))
testUnsuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Body: []byte("body"),
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func TestInMemoryStore_Insert(t *testing.T) {
store := NewInMemoryStore()
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
if len(store.serviceStatuses) != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", len(store.serviceStatuses))
}
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
serviceResults, exists := results[key]
serviceStatus, exists := store.serviceStatuses[key]
if !exists {
t.Fatalf("In Memory Store should've contained key '%s', but didn't", key)
t.Fatalf("Store should've had key '%s', but didn't", key)
}
if len(serviceResults.Results) != 2 {
t.Fatalf("Service '%s' should've had 2 results, but actually returned %d", serviceResults.Name, len(serviceResults.Results))
if len(serviceStatus.Results) != 2 {
t.Fatalf("Service '%s' should've had 2 results, but actually returned %d", serviceStatus.Name, len(serviceStatus.Results))
}
for i, r := range serviceResults.Results {
expectedResult := resultsToInsert[i]
for i, r := range serviceStatus.Results {
expectedResult := store.GetServiceStatus(testService.Group, testService.Name).Results[i]
if r.HTTPStatus != expectedResult.HTTPStatus {
t.Errorf("Result at index %d should've had a HTTPStatus of %d, but was actually %d", i, expectedResult.HTTPStatus, r.HTTPStatus)
}
@@ -172,265 +139,63 @@ func TestStorage_InsertTwoResultsForSingleServiceIntoEmptyMemoryStore_ThenGetAll
}
}
func TestStorage_InsertTwoResultsTwoServicesIntoEmptyMemoryStore_ThenGetAllReturnsTwoServicesWithOneResultEach(t *testing.T) {
memoryStore.Clear()
result1 := core.Result{
HTTPStatus: 404,
DNSRCode: "DNSRCode",
Body: nil,
Hostname: "Hostname",
IP: "IP",
Connected: false,
Duration: time.Second * 2,
Errors: nil,
ConditionResults: nil,
Success: false,
Timestamp: time.Now(),
CertificateExpiration: time.Second * 2,
}
result2 := core.Result{
HTTPStatus: 200,
DNSRCode: "DNSRCode",
Body: nil,
Hostname: "Hostname",
IP: "IP",
Connected: true,
Duration: time.Second * 2,
Errors: nil,
ConditionResults: nil,
Success: true,
Timestamp: time.Now(),
CertificateExpiration: time.Second * 2,
}
testService2 := core.Service{
Name: "Name2",
Group: "Group",
URL: "URL",
DNS: &core.DNS{QueryType: "QueryType", QueryName: "QueryName"},
Method: "Method",
Body: "Body",
GraphQL: false,
Headers: nil,
Interval: time.Second * 2,
Conditions: nil,
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
memoryStore.Insert(&testService, &result1)
memoryStore.Insert(&testService2, &result2)
results := memoryStore.GetAll()
if len(results) != 2 {
t.Fatalf("MemoryStore should've returned 2 results, but actually returned %d", len(results))
}
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
serviceResults1, exists := results[key]
if !exists {
t.Fatalf("In Memory Store should've contained key '%s', but didn't", key)
}
if len(serviceResults1.Results) != 1 {
t.Fatalf("Service '%s' should've had 1 results, but actually returned %d", serviceResults1.Name, len(serviceResults1.Results))
}
key = fmt.Sprintf("%s_%s", testService2.Group, testService2.Name)
serviceResults2, exists := results[key]
if !exists {
t.Fatalf("In Memory Store should've contained key '%s', but didn't", key)
}
if len(serviceResults2.Results) != 1 {
t.Fatalf("Service '%s' should've had 1 results, but actually returned %d", serviceResults1.Name, len(serviceResults1.Results))
}
}
func TestStorage_InsertResultForServiceWithErrorsIntoEmptyMemoryStore_ThenGetAllReturnsOneResultWithErrors(t *testing.T) {
memoryStore.Clear()
errors := []string{
"error1",
"error2",
}
result1 := core.Result{
HTTPStatus: 404,
DNSRCode: "DNSRCode",
Body: nil,
Hostname: "Hostname",
IP: "IP",
Connected: false,
Duration: time.Second * 2,
Errors: errors,
ConditionResults: nil,
Success: false,
Timestamp: time.Now(),
CertificateExpiration: time.Second * 2,
}
memoryStore.Insert(&testService, &result1)
results := memoryStore.GetAll()
if len(results) != 1 {
t.Fatalf("MemoryStore should've returned 1 results, but actually returned %d", len(results))
}
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
serviceResults, exists := results[key]
if !exists {
t.Fatalf("In Memory Store should've contained key '%s', but didn't", key)
}
if len(serviceResults.Results) != 1 {
t.Fatalf("Service '%s' should've had 2 results, but actually returned %d", serviceResults.Name, len(serviceResults.Results))
}
actualResult := serviceResults.Results[0]
if len(actualResult.Errors) != len(errors) {
t.Errorf("Service result should've had 2 errors, but actually had %d errors", len(actualResult.Errors))
}
for i, err := range actualResult.Errors {
if err != errors[i] {
t.Errorf("Error at index %d should've been %s, but was actually %s", i, errors[i], err)
}
}
}
func TestStorage_InsertResultForServiceWithConditionResultsIntoEmptyMemoryStore_ThenGetAllReturnsOneResultWithConditionResults(t *testing.T) {
memoryStore.Clear()
crs := []*core.ConditionResult{
{
Condition: "condition1",
Success: true,
},
{
Condition: "condition2",
Success: false,
},
}
result := core.Result{
HTTPStatus: 404,
DNSRCode: "DNSRCode",
Body: nil,
Hostname: "Hostname",
IP: "IP",
Connected: false,
Duration: time.Second * 2,
Errors: nil,
ConditionResults: crs,
Success: false,
Timestamp: time.Now(),
CertificateExpiration: time.Second * 2,
}
memoryStore.Insert(&testService, &result)
results := memoryStore.GetAll()
if len(results) != 1 {
t.Fatalf("MemoryStore should've returned 1 results, but actually returned %d", len(results))
}
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
serviceResults, exists := results[key]
if !exists {
t.Fatalf("In Memory Store should've contained key '%s', but didn't", key)
}
if len(serviceResults.Results) != 1 {
t.Fatalf("Service '%s' should've had 2 results, but actually returned %d", serviceResults.Name, len(serviceResults.Results))
}
actualResult := serviceResults.Results[0]
if len(actualResult.ConditionResults) != len(crs) {
t.Errorf("Service result should've had 2 ConditionResults, but actually had %d ConditionResults", len(actualResult.Errors))
}
for i, cr := range actualResult.ConditionResults {
if cr.Condition != crs[i].Condition {
t.Errorf("ConditionResult at index %d should've had condition %s, but was actually %s", i, crs[i].Condition, cr.Condition)
}
if cr.Success != crs[i].Success {
t.Errorf("ConditionResult at index %d should've had success value of %t, but was actually %t", i, crs[i].Success, cr.Success)
}
}
}
func TestStorage_MultipleMemoryStoreInstancesReferToDifferentInternalMaps(t *testing.T) {
memoryStore.Clear()
currentMap := memoryStore.GetAll()
otherMemoryStore := NewInMemoryStore()
otherMemoryStoresMap := otherMemoryStore.GetAll()
if len(currentMap) != len(otherMemoryStoresMap) {
t.Errorf("Multiple memory stores should refer to the different internal maps, but 'memoryStore' returned %d results, and 'otherMemoryStore' returned %d results", len(currentMap), len(otherMemoryStoresMap))
}
memoryStore.Insert(&testService, &core.Result{})
currentMap = memoryStore.GetAll()
otherMemoryStoresMap = otherMemoryStore.GetAll()
if len(currentMap) == len(otherMemoryStoresMap) {
t.Errorf("Multiple memory stores should refer to different internal maps, but 'memoryStore' returned %d results after inserting, and 'otherMemoryStore' returned %d results after inserting", len(currentMap), len(otherMemoryStoresMap))
}
otherMemoryStore.Clear()
currentMap = memoryStore.GetAll()
otherMemoryStoresMap = otherMemoryStore.GetAll()
if len(currentMap) == len(otherMemoryStoresMap) {
t.Errorf("Multiple memory stores should refer to different internal maps, but 'memoryStore' returned %d results after clearing, and 'otherMemoryStore' returned %d results after clearing", len(currentMap), len(otherMemoryStoresMap))
}
}
func TestStorage_ModificationsToReturnedMapDoNotAffectInternalMap(t *testing.T) {
memoryStore.Clear()
memoryStore.Insert(&testService, &core.Result{})
modifiedResults := memoryStore.GetAll()
for k := range modifiedResults {
delete(modifiedResults, k)
}
results := memoryStore.GetAll()
if len(modifiedResults) == len(results) {
t.Errorf("Returned map from GetAll should be free to modify by the caller without affecting internal in-memory map, but length of results from in-memory map (%d) was equal to the length of results in modified map (%d)", len(results), len(modifiedResults))
}
}
func TestStorage_GetServiceStatusForExistingStatusReturnsThatServiceStatus(t *testing.T) {
memoryStore.Clear()
memoryStore.Insert(&testService, &core.Result{})
serviceStatus := memoryStore.GetServiceStatus(testService.Group, testService.Name)
func TestInMemoryStore_GetServiceStatus(t *testing.T) {
store := NewInMemoryStore()
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
serviceStatus := store.GetServiceStatus(testService.Group, testService.Name)
if serviceStatus == nil {
t.Errorf("Returned service status for group '%s' and name '%s' was nil after inserting the service into the store", testService.Group, testService.Name)
t.Fatalf("serviceStatus shouldn't have been nil")
}
if serviceStatus.Uptime == nil {
t.Fatalf("serviceStatus.Uptime shouldn't have been nil")
}
if serviceStatus.Uptime.LastHour != 0.5 {
t.Errorf("serviceStatus.Uptime.LastHour should've been 0.5")
}
if serviceStatus.Uptime.LastTwentyFourHours != 0.5 {
t.Errorf("serviceStatus.Uptime.LastTwentyFourHours should've been 0.5")
}
if serviceStatus.Uptime.LastSevenDays != 0.5 {
t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5")
}
fmt.Println(serviceStatus.Results[0].Timestamp.Format(time.RFC3339))
}
func TestStorage_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
memoryStore.Clear()
func TestInMemoryStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
store := NewInMemoryStore()
store.Insert(&testService, &testSuccessfulResult)
memoryStore.Insert(&testService, &core.Result{})
serviceStatus := memoryStore.GetServiceStatus("nonexistantgroup", "nonexistantname")
serviceStatus := store.GetServiceStatus("nonexistantgroup", "nonexistantname")
if serviceStatus != nil {
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, testService.Name)
}
serviceStatus = memoryStore.GetServiceStatus(testService.Group, "nonexistantname")
serviceStatus = store.GetServiceStatus(testService.Group, "nonexistantname")
if serviceStatus != nil {
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, "nonexistantname")
}
serviceStatus = memoryStore.GetServiceStatus("nonexistantgroup", testService.Name)
serviceStatus = store.GetServiceStatus("nonexistantgroup", testService.Name)
if serviceStatus != nil {
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", "nonexistantgroup", testService.Name)
}
}
func TestInMemoryStore_GetAllAsJSON(t *testing.T) {
store := NewInMemoryStore()
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
store.Insert(&testService, firstResult)
store.Insert(&testService, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
output, err := store.GetAllAsJSON()
if err != nil {
t.Fatal("shouldn't have returned an error, got", err.Error())
}
expectedOutput := `{"group_name":{"name":"name","group":"group","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"condition-results":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"condition-results":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"uptime":{"7d":0.5,"24h":0.5,"1h":0.5}}}`
if string(output) != expectedOutput {
t.Errorf("expected:\n %s\n\ngot:\n %s", expectedOutput, string(output))
}
}

View File

@@ -4,8 +4,7 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/TwinProduction/gocache)](https://goreportcard.com/report/github.com/TwinProduction/gocache)
[![codecov](https://codecov.io/gh/TwinProduction/gocache/branch/master/graph/badge.svg)](https://codecov.io/gh/TwinProduction/gocache)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwinProduction/gocache.svg)](https://github.com/TwinProduction/gocache)
[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/TwinProduction/gocache)
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gocache-server.svg)](https://cloud.docker.com/repository/docker/twinproduction/gocache-server)
[![Go Reference](https://pkg.go.dev/badge/github.com/TwinProduction/gocache.svg)](https://pkg.go.dev/github.com/TwinProduction/gocache)
gocache is an easy-to-use, high-performance, lightweight and thread-safe (goroutine-safe) in-memory key-value cache
with support for LRU and FIFO eviction policies as well as expiration, bulk operations and even persistence to file.
@@ -27,6 +26,7 @@ with support for LRU and FIFO eviction policies as well as expiration, bulk oper
- [Eviction](#eviction)
- [MaxSize](#maxsize)
- [MaxMemoryUsage](#maxmemoryusage)
- [Expiration](#expiration)
- [Server](#server)
- [Running the server with Docker](#running-the-server-with-docker)
- [Performance](#performance)
@@ -71,28 +71,31 @@ cache.StartJanitor()
```
### Functions
| Function | Description |
| --------------------------------- | ----------- |
| WithMaxSize | Sets the max size of the cache. `gocache.NoMaxSize` means there is no limit. If not set, the default max size is `gocache.DefaultMaxSize`.
| WithMaxMemoryUsage | Sets the max memory usage of the cache. `gocache.NoMaxMemoryUsage` means there is no limit. The default behavior is to not evict based on memory usage.
| WithEvictionPolicy | Sets the eviction algorithm to be used when the cache reaches the max size. If not set, the default eviction policy is `gocache.FirstInFirstOut` (FIFO).
| WithForceNilInterfaceOnNilPointer | Configures whether values with a nil pointer passed to write functions should be forcefully set to nil. Defaults to true.
| StartJanitor | Starts the janitor, which is in charge of deleting expired cache entries in the background.
| StopJanitor | Stops the janitor.
| Set | Same as `SetWithTTL`, but with no expiration (`gocache.NoExpiration`)
| SetAll | Same as `Set`, but in bulk
| SetWithTTL | Creates or updates a cache entry with the given key, value and expiration time. If the max size after the aforementioned operation is above the configured max size, the tail will be evicted. Depending on the eviction policy, the tail is defined as the oldest
| Get | Gets a cache entry by its key.
| GetByKeys | Gets a map of entries by their keys. The resulting map will contain all keys, even if some of the keys in the slice passed as parameter were not present in the cache.
| GetAll | Gets all cache entries.
| GetKeysByPattern | Retrieves a slice of keys that matches a given pattern.
| Delete | Removes a key from the cache.
| DeleteAll | Removes multiple keys from the cache.
| Count | Gets the size of the cache. This includes cache keys which may have already expired, but have not been removed yet.
| Clear | Wipes the cache.
| TTL | Gets the time until a cache key expires.
| Expire | Sets the expiration time of an existing cache key.
| SaveToFile | Stores the content of the cache to a file so that it can be read using `ReadFromFile`. See [persistence](#persistence).
| ReadFromFile | Populates the cache using a file created using `SaveToFile`. See [persistence](#persistence).
| Function | Description |
| ------------------ | ----------- |
| WithMaxSize | Sets the max size of the cache. `gocache.NoMaxSize` means there is no limit. If not set, the default max size is `gocache.DefaultMaxSize`.
| WithMaxMemoryUsage | Sets the max memory usage of the cache. `gocache.NoMaxMemoryUsage` means there is no limit. The default behavior is to not evict based on memory usage.
| WithEvictionPolicy | Sets the eviction algorithm to be used when the cache reaches the max size. If not set, the default eviction policy is `gocache.FirstInFirstOut` (FIFO).
| StartJanitor | Starts the janitor, which is in charge of deleting expired cache entries in the background.
| StopJanitor | Stops the janitor.
| Set | Same as `SetWithTTL`, but with no expiration (`gocache.NoExpiration`)
| SetAll | Same as `Set`, but in bulk
| SetWithTTL | Creates or updates a cache entry with the given key, value and expiration time. If the max size after the aforementioned operation is above the configured max size, the tail will be evicted. Depending on the eviction policy, the tail is defined as the oldest
| Get | Gets a cache entry by its key.
| GetAll | Gets a map of entries by their keys. The resulting map will contain all keys, even if some of the keys in the slice passed as parameter were not present in the cache.
| GetKeysByPattern | Retrieves a slice of keys that matches a given pattern.
| Delete | Removes a key from the cache.
| DeleteAll | Removes multiple keys from the cache.
| Count | Gets the size of the cache. This includes cache keys which may have already expired, but have not been removed yet.
| Clear | Wipes the cache.
| TTL | Gets the time until a cache key expires.
| Expire | Sets the expiration time of an existing cache key.
| SaveToFile | Stores the content of the cache to a file so that it can be read using `ReadFromFile`. See [persistence](#persistence).
| ReadFromFile | Populates the cache using a file created using `SaveToFile`. See [persistence](#persistence).
For further documentation, please refer to [Go Reference](https://pkg.go.dev/github.com/TwinProduction/gocache)
### Examples
@@ -102,13 +105,14 @@ cache.StartJanitor()
cache.Set("key", "value")
cache.Set("key", 1)
cache.Set("key", struct{ Text string }{Test: "value"})
cache.SetWithTTL("key", []byte("value"), 24*time.Hour)
```
#### Getting an entry
```go
value, ok := cache.Get("key")
value, exists := cache.Get("key")
```
You can also get multiple entries by using `cache.GetAll([]string{"key1", "key2"})`
You can also get multiple entries by using `cache.GetByKeys([]string{"key1", "key2"})`
#### Deleting an entry
```go
@@ -122,8 +126,9 @@ package main
import (
"fmt"
"github.com/TwinProduction/gocache"
"time"
"github.com/TwinProduction/gocache"
)
func main() {
@@ -136,8 +141,8 @@ func main() {
value, exists := cache.Get("key")
fmt.Printf("[Get] key=key; value=%s; exists=%v\n", value, exists)
for key, value := range cache.GetAll([]string{"k1", "k2", "k3"}) {
fmt.Printf("[GetAll] key=%s; value=%s\n", key, value)
for key, value := range cache.GetByKeys([]string{"k1", "k2", "k3"}) {
fmt.Printf("[GetByKeys] key=%s; value=%s\n", key, value)
}
for _, key := range cache.GetKeysByPattern("key*", 0) {
fmt.Printf("[GetKeysByPattern] key=%s\n", key)
@@ -174,9 +179,9 @@ func main() {
```
[Get] key=key; value=value; exists=true
[GetAll] key=k2; value=v2
[GetAll] key=k3; value=v3
[GetAll] key=k1; value=v1
[GetByKeys] key=k2; value=v2
[GetByKeys] key=k3; value=v3
[GetByKeys] key=k1; value=v1
[GetKeysByPattern] key=key
[GetKeysByPattern] key=key-with-ttl
Cache size before persisting cache to file: 5
@@ -248,6 +253,7 @@ you'll be fine.
## Eviction
### MaxSize
Eviction by MaxSize is the default behavior, and is also the most efficient.
@@ -258,7 +264,7 @@ cache := gocache.NewCache().WithMaxSize(1000)
This means that whenever an operation causes the total size of the cache to go above 1000, the tail will be evicted.
### MaxMemoryUsage
Eviction by MaxMemoryUsage is **disabled by default**, and is still a work in progress.
Eviction by MaxMemoryUsage is **disabled by default**, and is in alpha.
The code below will create a cache that has a maximum memory usage of 50MB:
```go
@@ -268,7 +274,7 @@ This means that whenever an operation causes the total memory usage of the cache
will be evicted.
Unlike evictions caused by reaching the MaxSize, evictions triggered by MaxMemoryUsage may lead to multiple entries
being evicted in a row. The reason for this is that if, for instance, you had 500 entries of 0.1MB each and you suddenly added
being evicted in a row. The reason for this is that if, for instance, you had 100 entries of 0.1MB each and you suddenly added
a single entry of 10MB, 100 entries would need to be evicted to make enough space for that new big entry.
It's very important to keep in mind that eviction by MaxMemoryUsage is approximate.
@@ -284,6 +290,18 @@ As previously mentioned, this is a work in progress, and here's a list of the th
- Adding an entry bigger than the configured MaxMemoryUsage will work, but it will evict all other entries.
## Expiration
There are two ways that the deletion of expired keys can take place:
- Active
- Passive
**Active deletion of expired keys** happens when an attempt is made to access the value of a cache entry that expired.
`Get`, `GetByKeys` and `GetAll` are the only functions that can trigger active deletion of expired keys.
**Passive deletion of expired keys** runs in the background and is managed by the janitor.
If you do not start the janitor, there will be no passive deletion of expired keys.
## Server
For the sake of convenience, a ready-to-go cache server is available
through the `gocacheserver` package.
@@ -330,12 +348,14 @@ Any Redis client should be able to interact with the server, though only the fol
## Running the server with Docker
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gocache-server.svg)](https://cloud.docker.com/repository/docker/twinproduction/gocache-server)
To build it locally, refer to the Makefile's `docker-build` and `docker-run` steps.
Note that the server version of gocache is still under development.
```
docker run --name gocache-server -p 6379:6379 twinproduction/gocache-server:v0.1.0
docker run --name gocache-server -p 6379:6379 twinproduction/gocache-server
```
@@ -362,45 +382,52 @@ but if you're looking into using a library like gocache, odds are, you want more
| mem | 32G DDR4 |
```
BenchmarkMap_Get-8 47943618 26.6 ns/op
BenchmarkMap_SetSmallValue-8 3800810 394 ns/op
BenchmarkMap_SetMediumValue-8 3904794 400 ns/op
BenchmarkMap_SetLargeValue-8 3934033 383 ns/op
BenchmarkCache_Get-8 27254640 45.0 ns/op
BenchmarkCache_SetSmallValue-8 2991620 401 ns/op
BenchmarkCache_SetMediumValue-8 3051128 381 ns/op
BenchmarkCache_SetLargeValue-8 2995904 382 ns/op
BenchmarkCache_SetSmallValueWhenUsingMaxMemoryUsage-8 2752288 428 ns/op
BenchmarkCache_SetMediumValueWhenUsingMaxMemoryUsage-8 2744899 436 ns/op
BenchmarkCache_SetLargeValueWhenUsingMaxMemoryUsage-8 2756816 430 ns/op
BenchmarkCache_SetSmallValueWithMaxSize10-8 5308886 226 ns/op
BenchmarkCache_SetMediumValueWithMaxSize10-8 5304098 226 ns/op
BenchmarkCache_SetLargeValueWithMaxSize10-8 5277986 227 ns/op
BenchmarkCache_SetSmallValueWithMaxSize1000-8 5130580 236 ns/op
BenchmarkCache_SetMediumValueWithMaxSize1000-8 5102404 237 ns/op
BenchmarkCache_SetLargeValueWithMaxSize1000-8 5084695 237 ns/op
BenchmarkCache_SetSmallValueWithMaxSize100000-8 3858066 315 ns/op
BenchmarkCache_SetMediumValueWithMaxSize100000-8 3909277 315 ns/op
BenchmarkCache_SetLargeValueWithMaxSize100000-8 3870913 315 ns/op
BenchmarkCache_SetSmallValueWithMaxSize100000AndLRU-8 3856012 316 ns/op
BenchmarkCache_SetMediumValueWithMaxSize100000AndLRU-8 3809518 316 ns/op
BenchmarkCache_SetLargeValueWithMaxSize100000AndLRU-8 3834754 318 ns/op
BenchmarkCache_GetAndSetConcurrently-8 1779258 672 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndLRU-8 2569590 487 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndFIFO-8 2608369 474 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndNoEvictionAndLRU-8 2185795 582 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndNoEvictionAndFIFO-8 2238811 568 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithFrequentEvictionsAndLRU-8 3726714 320 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithFrequentEvictionsAndFIFO-8 3682808 325 ns/op
BenchmarkCache_GetConcurrentlyWithLRU-8 1536589 739 ns/op
BenchmarkCache_GetConcurrentlyWithFIFO-8 1558513 737 ns/op
BenchmarkCache_GetKeysThatDoNotExistConcurrently-8 10173138 119 ns/op
BenchmarkMap_Get-8 95936680 26.3 ns/op
BenchmarkMap_SetSmallValue-8 7738132 424 ns/op
BenchmarkMap_SetMediumValue-8 7766346 424 ns/op
BenchmarkMap_SetLargeValue-8 7947063 435 ns/op
BenchmarkCache_Get-8 54549049 45.7 ns/op
BenchmarkCache_SetSmallValue-8 35225013 69.2 ns/op
BenchmarkCache_SetMediumValue-8 5952064 412 ns/op
BenchmarkCache_SetLargeValue-8 5969121 411 ns/op
BenchmarkCache_GetUsingLRU-8 54545949 45.6 ns/op
BenchmarkCache_SetSmallValueUsingLRU-8 5909504 419 ns/op
BenchmarkCache_SetMediumValueUsingLRU-8 5910885 418 ns/op
BenchmarkCache_SetLargeValueUsingLRU-8 5867544 419 ns/op
BenchmarkCache_SetSmallValueWhenUsingMaxMemoryUsage-8 5477178 462 ns/op
BenchmarkCache_SetMediumValueWhenUsingMaxMemoryUsage-8 5417595 475 ns/op
BenchmarkCache_SetLargeValueWhenUsingMaxMemoryUsage-8 5215263 479 ns/op
BenchmarkCache_SetSmallValueWithMaxSize10-8 10115574 236 ns/op
BenchmarkCache_SetMediumValueWithMaxSize10-8 10242792 241 ns/op
BenchmarkCache_SetLargeValueWithMaxSize10-8 10201894 241 ns/op
BenchmarkCache_SetSmallValueWithMaxSize1000-8 9637113 253 ns/op
BenchmarkCache_SetMediumValueWithMaxSize1000-8 9635175 253 ns/op
BenchmarkCache_SetLargeValueWithMaxSize1000-8 9598982 260 ns/op
BenchmarkCache_SetSmallValueWithMaxSize100000-8 7642584 337 ns/op
BenchmarkCache_SetMediumValueWithMaxSize100000-8 7407571 344 ns/op
BenchmarkCache_SetLargeValueWithMaxSize100000-8 7071360 345 ns/op
BenchmarkCache_SetSmallValueWithMaxSize100000AndLRU-8 7544194 332 ns/op
BenchmarkCache_SetMediumValueWithMaxSize100000AndLRU-8 7667004 344 ns/op
BenchmarkCache_SetLargeValueWithMaxSize100000AndLRU-8 7357642 338 ns/op
BenchmarkCache_GetAndSetMultipleConcurrently-8 1442306 1684 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndLRU-8 5117271 477 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndFIFO-8 5228412 475 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndNoEvictionAndLRU-8 5139195 529 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithRandomKeysAndNoEvictionAndFIFO-8 5251639 511 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithFrequentEvictionsAndLRU-8 7384626 334 ns/op
BenchmarkCache_GetAndSetConcurrentlyWithFrequentEvictionsAndFIFO-8 7361985 332 ns/op
BenchmarkCache_GetConcurrentlyWithLRU-8 3370784 726 ns/op
BenchmarkCache_GetConcurrentlyWithFIFO-8 3749994 681 ns/op
BenchmarkCache_GetKeysThatDoNotExistConcurrently-8 17647344 143 ns/op
```
## FAQ
### Why does the memory usage not go down?
> **NOTE**: As of Go 1.16, this will no longer apply. See [golang/go#42330](https://github.com/golang/go/issues/42330)
By default, Go uses `MADV_FREE` if the kernel supports it to release memory, which is significantly more efficient
than using `MADV_DONTNEED`. Unfortunately, this means that RSS doesn't go down unless the OS actually needs the
memory.
@@ -422,4 +449,4 @@ You can reproduce this by following the steps below:
**Substituting gocache for a normal map will yield the same result.**
If the released memory still appearing as used is a problem for you,
you can set the environment variable `GODEBUG` to `madvdontneed=1`.
you can set the environment variable `GODEBUG` to `madvdontneed=1`.

View File

@@ -6,8 +6,12 @@ import (
"unsafe"
)
// Entry is a cache entry
type Entry struct {
Key string
// Key is the name of the cache entry
Key string
// Value is the value of the cache entry
Value interface{}
// RelevantTimestamp is the variable used to store either:
@@ -24,10 +28,12 @@ type Entry struct {
previous *Entry
}
// Accessed updates the Entry's RelevantTimestamp to now
func (entry *Entry) Accessed() {
entry.RelevantTimestamp = time.Now()
}
// Expired returns whether the Entry has expired
func (entry Entry) Expired() bool {
if entry.Expiration > 0 {
if time.Now().UnixNano() > entry.Expiration {
@@ -37,6 +43,7 @@ func (entry Entry) Expired() bool {
return false
}
// SizeInBytes returns the size of an entry in bytes, approximately.
func (entry *Entry) SizeInBytes() int {
return toBytes(entry.Key) + toBytes(entry.Value) + 32
}

View File

@@ -2,13 +2,16 @@ package gocache
import (
"errors"
"reflect"
"sync"
"time"
)
const (
var (
Debug = false
)
const (
// NoMaxSize means that the cache has no maximum number of entries in the cache
// Setting Cache.maxSize to this value also means there will be no eviction
NoMaxSize = 0
@@ -23,15 +26,14 @@ const (
NoExpiration = -1
Kilobyte = 1024
Megabyte = 1024 * 1024
Gigabyte = 1024 * 1024 * 1024
Megabyte = 1024 * Kilobyte
Gigabyte = 1024 * Megabyte
)
var (
ErrKeyDoesNotExist = errors.New("key does not exist")
ErrKeyHasNoExpiration = errors.New("key has no expiration")
ErrJanitorAlreadyRunning = errors.New("janitor is already running")
ErrAutoSaveAlreadyRunning = errors.New("autosave is already running")
ErrKeyDoesNotExist = errors.New("key does not exist")
ErrKeyHasNoExpiration = errors.New("key has no expiration")
ErrJanitorAlreadyRunning = errors.New("janitor is already running")
)
// Cache is the core struct of gocache which contains the data as well as all relevant configuration fields
@@ -68,6 +70,15 @@ type Cache struct {
// memoryUsage is the approximate memory usage of the cache (dataset only) in bytes
memoryUsage int
// forceNilInterfaceOnNilPointer determines whether all Set-like functions should set a value as nil if the
// interface passed has a nil value but not a nil type.
//
// By default, interfaces are only nil when both their type and value is nil.
// This means that when you pass a pointer to a nil value, the type of the interface
// will still show as nil, which means that if you don't cast the interface after
// retrieving it, a nil check will return that the value is not false.
forceNilInterfaceOnNilPointer bool
}
// MaxSize returns the maximum amount of keys that can be present in the cache before
@@ -87,8 +98,16 @@ func (cache *Cache) EvictionPolicy() EvictionPolicy {
}
// Stats returns statistics from the cache
func (cache *Cache) Stats() *Statistics {
return cache.stats
func (cache *Cache) Stats() Statistics {
cache.mutex.RLock()
stats := Statistics{
EvictedKeys: cache.stats.EvictedKeys,
ExpiredKeys: cache.stats.ExpiredKeys,
Hits: cache.stats.Hits,
Misses: cache.stats.Misses,
}
cache.mutex.RUnlock()
return stats
}
// MemoryUsage returns the current memory usage of the cache's dataset in bytes
@@ -103,6 +122,9 @@ func (cache *Cache) WithMaxSize(maxSize int) *Cache {
if maxSize < 0 {
maxSize = NoMaxSize
}
if maxSize != NoMaxSize && cache.Count() == 0 {
cache.entries = make(map[string]*Entry, maxSize)
}
cache.maxSize = maxSize
return cache
}
@@ -127,20 +149,62 @@ func (cache *Cache) WithEvictionPolicy(policy EvictionPolicy) *Cache {
return cache
}
// WithForceNilInterfaceOnNilPointer sets whether all Set-like functions should set a value as nil if the
// interface passed has a nil value but not a nil type.
//
// In Go, an interface is only nil if both its type and value are nil, which means that a nil pointer
// (e.g. (*Struct)(nil)) will retain its attribution to the type, and the unmodified value returned from
// Cache.Get, for instance, would return false when compared with nil if this option is set to false.
//
// We can bypass this by detecting if the interface's value is nil and setting it to nil rather than
// a nil pointer, which will make the value returned from Cache.Get return true when compared with nil.
// This is exactly what passing true to WithForceNilInterfaceOnNilPointer does, and it's also the default behavior.
//
// Alternatively, you may pass false to WithForceNilInterfaceOnNilPointer, which will mean that you'll have
// to cast the value returned from Cache.Get to its original type to check for whether the pointer returned
// is nil or not.
//
// If set to true:
// cache := gocache.NewCache().WithForceNilInterfaceOnNilPointer(true)
// cache.Set("key", (*Struct)(nil))
// value, _ := cache.Get("key")
// // the following returns true, because the interface{} was forcefully set to nil
// if value == nil {}
// // the following will panic, because the value has been casted to its type
// if value.(*Struct) == nil {}
//
// If set to false:
// cache := gocache.NewCache().WithForceNilInterfaceOnNilPointer(false)
// cache.Set("key", (*Struct)(nil))
// value, _ := cache.Get("key")
// // the following returns false, because the interface{} returned has a non-nil type (*Struct)
// if value == nil {}
// // the following returns true, because the value has been casted to its type
// if value.(*Struct) == nil {}
//
// In other words, if set to true, you do not need to cast the value returned from the cache to
// to check if the value is nil.
//
// Defaults to true
func (cache *Cache) WithForceNilInterfaceOnNilPointer(forceNilInterfaceOnNilPointer bool) *Cache {
cache.forceNilInterfaceOnNilPointer = forceNilInterfaceOnNilPointer
return cache
}
// NewCache creates a new Cache
//
// Should be used in conjunction with Cache.WithMaxSize, Cache.WithMaxMemoryUsage and/or Cache.WithEvictionPolicy
//
// gocache.NewCache().WithMaxSize(10000).WithEvictionPolicy(gocache.LeastRecentlyUsed)
//
func NewCache() *Cache {
return &Cache{
maxSize: DefaultMaxSize,
evictionPolicy: FirstInFirstOut,
stats: &Statistics{},
entries: make(map[string]*Entry),
mutex: sync.RWMutex{},
stopJanitor: nil,
maxSize: DefaultMaxSize,
evictionPolicy: FirstInFirstOut,
stats: &Statistics{},
entries: make(map[string]*Entry),
mutex: sync.RWMutex{},
stopJanitor: nil,
forceNilInterfaceOnNilPointer: true,
}
}
@@ -150,13 +214,22 @@ func (cache *Cache) Set(key string, value interface{}) {
}
// SetWithTTL creates or updates a key with a given value and sets an expiration time (-1 is NoExpiration)
//
// The TTL provided must be greater than 0, or NoExpiration (-1). If a negative value that isn't -1 (NoExpiration) is
// provided, the entry will not be created if the key doesn't exist
func (cache *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration) {
// An interface is only nil if both its value and its type are nil, however, passing a pointer
if cache.forceNilInterfaceOnNilPointer {
if value != nil && (reflect.ValueOf(value).Kind() == reflect.Ptr && reflect.ValueOf(value).IsNil()) {
value = nil
}
}
cache.mutex.Lock()
entry, ok := cache.get(key)
if !ok {
// A negative TTL that isn't -1 (NoExpiration) is an entry that will expire instantly,
// A negative TTL that isn't -1 (NoExpiration) or 0 is an entry that will expire instantly,
// so might as well just not create it in the first place
if ttl != NoExpiration && ttl < 0 {
if ttl != NoExpiration && ttl < 1 {
cache.mutex.Unlock()
return
}
@@ -178,6 +251,13 @@ func (cache *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration)
cache.memoryUsage += entry.SizeInBytes()
}
} else {
// A negative TTL that isn't -1 (NoExpiration) or 0 is an entry that will expire instantly,
// so might as well just delete it immediately instead of updating it
if ttl != NoExpiration && ttl < 1 {
cache.delete(key)
cache.mutex.Unlock()
return
}
if cache.maxMemoryUsage != NoMaxMemoryUsage {
// Substract the old entry from the cache's memoryUsage
cache.memoryUsage -= entry.SizeInBytes()
@@ -234,12 +314,13 @@ func (cache *Cache) Get(key string) (interface{}, bool) {
cache.stats.Misses++
return nil, false
}
cache.stats.Hits++
if entry.Expired() {
cache.stats.ExpiredKeys++
cache.delete(key)
cache.mutex.Unlock()
return nil, false
}
cache.stats.Hits++
if cache.evictionPolicy == LeastRecentlyUsed {
entry.Accessed()
if cache.head == entry {
@@ -253,12 +334,11 @@ func (cache *Cache) Get(key string) (interface{}, bool) {
return entry.Value, true
}
// GetAll retrieves multiple entries using the keys passed as parameter
// All keys are returned in the map, regardless of whether they exist or not,
// however, entries that do not exist in the cache will return nil, meaning that
// there is no way of determining whether a key genuinely has the value nil, or
// whether it doesn't exist in the cache using only this function
func (cache *Cache) GetAll(keys []string) map[string]interface{} {
// GetByKeys retrieves multiple entries using the keys passed as parameter
// All keys are returned in the map, regardless of whether they exist or not, however, entries that do not exist in the
// cache will return nil, meaning that there is no way of determining whether a key genuinely has the value nil, or
// whether it doesn't exist in the cache using only this function.
func (cache *Cache) GetByKeys(keys []string) map[string]interface{} {
entries := make(map[string]interface{})
for _, key := range keys {
entries[key], _ = cache.Get(key)
@@ -266,18 +346,51 @@ func (cache *Cache) GetAll(keys []string) map[string]interface{} {
return entries
}
// GetAll retrieves all cache entries
//
// If the eviction policy is LeastRecentlyUsed, note that unlike Get and GetByKeys, this does not update the last access
// timestamp. The reason for this is that since all cache entries will be accessed, updating the last access timestamp
// would provide very little benefit while harming the ability to accurately determine the next key that will be evicted
//
// You should probably avoid using this if you have a lot of entries.
//
// GetKeysByPattern is a good alternative if you want to retrieve entries that you do not have the key for, as it only
// retrieves the keys and does not trigger active eviction and has a parameter for setting a limit to the number of keys
// you wish to retrieve.
func (cache *Cache) GetAll() map[string]interface{} {
entries := make(map[string]interface{})
cache.mutex.Lock()
for key, entry := range cache.entries {
if entry.Expired() {
cache.delete(key)
continue
}
entries[key] = entry.Value
}
cache.stats.Hits += uint64(len(entries))
cache.mutex.Unlock()
return entries
}
// GetKeysByPattern retrieves a slice of keys that match a given pattern
// If the limit is set to 0, the entire cache will be searched for matching keys.
// If the limit is above 0, the search will stop once the specified number of matching keys have been found.
//
// e.g. cache.GetKeysByPattern("*some*", 0) will return all keys containing "some" in them
// e.g. cache.GetKeysByPattern("*some*", 5) will return 5 keys (or less) containing "some" in them
// e.g.
// cache.GetKeysByPattern("*some*", 0) will return all keys containing "some" in them
// cache.GetKeysByPattern("*some*", 5) will return 5 keys (or less) containing "some" in them
//
// Note that GetKeysByPattern does not trigger evictions, nor does it count as accessing the entry.
// Note that GetKeysByPattern does not trigger active evictions, nor does it count as accessing the entry, the latter
// only applying if the cache uses the LeastRecentlyUsed eviction policy.
// The reason for that behavior is that these two (active eviction and access) only applies when you access the value
// of the cache entry, and this function only returns the keys.
func (cache *Cache) GetKeysByPattern(pattern string, limit int) []string {
var matchingKeys []string
cache.mutex.RLock()
for key := range cache.entries {
cache.mutex.Lock()
for key, value := range cache.entries {
if value.Expired() {
continue
}
if MatchPattern(pattern, key) {
matchingKeys = append(matchingKeys, key)
if limit > 0 && len(matchingKeys) >= limit {
@@ -285,7 +398,7 @@ func (cache *Cache) GetKeysByPattern(pattern string, limit int) []string {
}
}
}
cache.mutex.RUnlock()
cache.mutex.Unlock()
return matchingKeys
}

View File

@@ -2,7 +2,6 @@ package gocache
import (
"log"
"runtime"
"time"
)
@@ -24,9 +23,10 @@ const (
)
// StartJanitor starts the janitor on a different goroutine
// The janitor's job is to delete expired keys in the background.
// The janitor's job is to delete expired keys in the background, in other words, it takes care of passive eviction.
// It can be stopped by calling Cache.StopJanitor.
// If you do not start the janitor, expired keys will only be deleted when they are accessed through Get
// If you do not start the janitor, expired keys will only be deleted when they are accessed through Get, GetByKeys, or
// GetAll.
func (cache *Cache) StartJanitor() error {
if cache.stopJanitor != nil {
return ErrJanitorAlreadyRunning
@@ -109,26 +109,32 @@ func (cache *Cache) StartJanitor() error {
}
cache.mutex.Unlock()
case <-cache.stopJanitor:
cache.stopJanitor = nil
cache.stopJanitor <- true
return
}
}
}()
if Debug {
go func() {
var m runtime.MemStats
for {
runtime.ReadMemStats(&m)
log.Printf("Alloc=%vMB; HeapReleased=%vMB; Sys=%vMB; HeapInUse=%vMB; HeapObjects=%v; HeapObjectsFreed=%v; GC=%v; cache.memoryUsage=%vMB; cacheSize=%d\n", m.Alloc/1024/1024, m.HeapReleased/1024/1024, m.Sys/1024/1024, m.HeapInuse/1024/1024, m.HeapObjects, m.Frees, m.NumGC, cache.memoryUsage/1024/1024, cache.Count())
time.Sleep(3 * time.Second)
}
}()
}
//if Debug {
// go func() {
// var m runtime.MemStats
// for {
// runtime.ReadMemStats(&m)
// log.Printf("Alloc=%vMB; HeapReleased=%vMB; Sys=%vMB; HeapInUse=%vMB; HeapObjects=%v; HeapObjectsFreed=%v; GC=%v; cache.memoryUsage=%vMB; cacheSize=%d\n", m.Alloc/1024/1024, m.HeapReleased/1024/1024, m.Sys/1024/1024, m.HeapInuse/1024/1024, m.HeapObjects, m.Frees, m.NumGC, cache.memoryUsage/1024/1024, cache.Count())
// time.Sleep(3 * time.Second)
// }
// }()
//}
return nil
}
// StopJanitor stops the janitor
func (cache *Cache) StopJanitor() {
cache.stopJanitor <- true
time.Sleep(100 * time.Millisecond)
if cache.stopJanitor != nil {
// Tell the janitor to stop, and then wait for the janitor to reply on the same channel that it's stopping
// This may seem a bit odd, but this allows us to avoid a data race condition in which setting cache.stopJanitor
// to nil
cache.stopJanitor <- true
<-cache.stopJanitor
cache.stopJanitor = nil
}
}

View File

@@ -3,11 +3,12 @@ package gocache
import (
"bytes"
"encoding/gob"
"github.com/boltdb/bolt"
"log"
"os"
"sort"
"time"
"github.com/boltdb/bolt"
)
// SaveToFile stores the content of the cache to a file so that it can be read using

2
vendor/modules.txt vendored
View File

@@ -1,7 +1,7 @@
# cloud.google.com/go v0.74.0
## explicit
cloud.google.com/go/compute/metadata
# github.com/TwinProduction/gocache v0.3.0
# github.com/TwinProduction/gocache v1.1.0
## explicit
github.com/TwinProduction/gocache
# github.com/beorn7/perks v1.0.1

View File

@@ -26,30 +26,28 @@ func handleAlertsToTrigger(service *core.Service, result *core.Result, cfg *conf
service.NumberOfFailuresInARow++
for _, alert := range service.Alerts {
// If the alert hasn't been triggered, move to the next one
if !alert.Enabled || alert.FailureThreshold != service.NumberOfFailuresInARow {
if !alert.Enabled || alert.FailureThreshold > service.NumberOfFailuresInARow {
continue
}
if alert.Triggered {
if cfg.Debug {
log.Printf("[watchdog][handleAlertsToTrigger] Alert with description='%s' has already been TRIGGERED, skipping", alert.Description)
log.Printf("[watchdog][handleAlertsToTrigger] Alert for service=%s with description='%s' has already been TRIGGERED, skipping", service.Name, alert.Description)
}
continue
}
alertProvider := config.GetAlertingProviderByAlertType(cfg, alert.Type)
if alertProvider != nil && alertProvider.IsValid() {
log.Printf("[watchdog][handleAlertsToTrigger] Sending %s alert because alert with description='%s' has been TRIGGERED", alert.Type, alert.Description)
log.Printf("[watchdog][handleAlertsToTrigger] Sending %s alert because alert for service=%s with description='%s' has been TRIGGERED", alert.Type, service.Name, alert.Description)
customAlertProvider := alertProvider.ToCustomAlertProvider(service, alert, result, false)
// TODO: retry on error
var err error
// We need to extract the DedupKey from PagerDuty's response
if alert.Type == core.PagerDutyAlert {
var body []byte
body, err = customAlertProvider.Send(service.Name, alert.Description, false)
if err == nil {
if body, err = customAlertProvider.Send(service.Name, alert.Description, false); err == nil {
var response pagerDutyResponse
err = json.Unmarshal(body, &response)
if err != nil {
log.Printf("[watchdog][handleAlertsToTrigger] Ran into error unmarshaling pager duty response: %s", err.Error())
if err = json.Unmarshal(body, &response); err != nil {
log.Printf("[watchdog][handleAlertsToTrigger] Ran into error unmarshaling pagerduty response: %s", err.Error())
} else {
alert.ResolveKey = response.DedupKey
}
@@ -59,11 +57,10 @@ func handleAlertsToTrigger(service *core.Service, result *core.Result, cfg *conf
_, err = customAlertProvider.Send(service.Name, alert.Description, false)
}
if err != nil {
log.Printf("[watchdog][handleAlertsToTrigger] Ran into error sending an alert: %s", err.Error())
log.Printf("[watchdog][handleAlertsToTrigger] Failed to send an alert for service=%s: %s", service.Name, err.Error())
} else {
alert.Triggered = true
}
} else {
log.Printf("[watchdog][handleAlertsToResolve] Not sending alert of type=%s despite being TRIGGERED, because the provider wasn't configured properly", alert.Type)
}
@@ -76,18 +73,20 @@ func handleAlertsToResolve(service *core.Service, result *core.Result, cfg *conf
if !alert.Enabled || !alert.Triggered || alert.SuccessThreshold > service.NumberOfSuccessesInARow {
continue
}
// Even if the alert provider returns an error, we still set the alert's Triggered variable to false.
// Further explanation can be found on Alert's Triggered field.
alert.Triggered = false
if !alert.SendOnResolved {
continue
}
alertProvider := config.GetAlertingProviderByAlertType(cfg, alert.Type)
if alertProvider != nil && alertProvider.IsValid() {
log.Printf("[watchdog][handleAlertsToResolve] Sending %s alert because alert with description='%s' has been RESOLVED", alert.Type, alert.Description)
log.Printf("[watchdog][handleAlertsToResolve] Sending %s alert because alert for service=%s with description='%s' has been RESOLVED", alert.Type, service.Name, alert.Description)
customAlertProvider := alertProvider.ToCustomAlertProvider(service, alert, result, true)
// TODO: retry on error
_, err := customAlertProvider.Send(service.Name, alert.Description, true)
if err != nil {
log.Printf("[watchdog][handleAlertsToResolve] Ran into error sending an alert: %s", err.Error())
log.Printf("[watchdog][handleAlertsToResolve] Failed to send an alert for service=%s: %s", service.Name, err.Error())
} else {
if alert.Type == core.PagerDutyAlert {
alert.ResolveKey = ""

332
watchdog/alerting_test.go Normal file
View File

@@ -0,0 +1,332 @@
package watchdog
import (
"os"
"testing"
"github.com/TwinProduction/gatus/alerting"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
)
func TestHandleAlerting(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Debug: true,
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
URL: "https://twinnation.org/health",
Method: "GET",
},
},
}
config.Set(cfg)
service := &core.Service{
URL: "http://example.com",
Alerts: []*core.Alert{
{
Type: core.CustomAlert,
Enabled: true,
FailureThreshold: 2,
SuccessThreshold: 3,
SendOnResolved: true,
Triggered: false,
},
},
}
verify(t, service, 0, 0, false, "The alert shouldn't start triggered")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, false, "The alert shouldn't have triggered")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 2, 0, true, "The alert should've triggered")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 3, 0, true, "The alert should still be triggered")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 4, 0, true, "The alert should still be triggered")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, true, "The alert should still be triggered (because service.Alerts[0].SuccessThreshold is 3)")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 2, true, "The alert should still be triggered (because service.Alerts[0].SuccessThreshold is 3)")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 3, false, "The alert should've been resolved")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 4, false, "The alert should no longer be triggered")
}
func TestHandleAlertingWhenAlertingConfigIsNil(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Debug: true,
Alerting: nil,
}
config.Set(cfg)
HandleAlerting(nil, nil)
}
func TestHandleAlertingWithBadAlertProvider(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{},
}
config.Set(cfg)
service := &core.Service{
URL: "http://example.com",
Alerts: []*core.Alert{
{
Type: core.CustomAlert,
Enabled: true,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: true,
Triggered: false,
},
},
}
verify(t, service, 0, 0, false, "The alert shouldn't start triggered")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, false, "The alert shouldn't have triggered")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 2, 0, false, "The alert shouldn't have triggered, because the provider wasn't configured properly")
}
func TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButServiceStartFailingAgain(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Debug: true,
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
URL: "https://twinnation.org/health",
Method: "GET",
},
},
}
config.Set(cfg)
service := &core.Service{
URL: "http://example.com",
Alerts: []*core.Alert{
{
Type: core.CustomAlert,
Enabled: true,
FailureThreshold: 2,
SuccessThreshold: 3,
SendOnResolved: true,
Triggered: true,
},
},
NumberOfFailuresInARow: 1,
}
// This test simulate an alert that was already triggered
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 2, 0, true, "The alert was already triggered at the beginning of this test")
}
func TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Debug: true,
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
URL: "https://twinnation.org/health",
Method: "GET",
},
},
}
config.Set(cfg)
service := &core.Service{
URL: "http://example.com",
Alerts: []*core.Alert{
{
Type: core.CustomAlert,
Enabled: true,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: false,
Triggered: true,
},
},
NumberOfFailuresInARow: 1,
}
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, false, "The alert should've been resolved")
}
func TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Debug: true,
Alerting: &alerting.Config{
PagerDuty: &pagerduty.AlertProvider{
IntegrationKey: "00000000000000000000000000000000",
},
},
}
config.Set(cfg)
service := &core.Service{
URL: "http://example.com",
Alerts: []*core.Alert{
{
Type: core.PagerDutyAlert,
Enabled: true,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: true,
Triggered: false,
},
},
NumberOfFailuresInARow: 0,
}
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, true, "")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, false, "The alert should've been resolved")
}
func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Debug: true,
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
URL: "https://twinnation.org/health",
Method: "GET",
},
},
}
config.Set(cfg)
service := &core.Service{
URL: "http://example.com",
Alerts: []*core.Alert{
{
Type: core.CustomAlert,
Enabled: true,
FailureThreshold: 2,
SuccessThreshold: 2,
SendOnResolved: true,
Triggered: false,
},
},
}
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, false, "")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 2, 0, false, "The alert should have failed to trigger, because the alert provider is returning an error")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 3, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 4, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 5, 0, true, "The alert should've been triggered because the alert provider is no longer returning an error")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, true, "The alert should've still been triggered")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 2, false, "The alert should've been resolved DESPITE THE ALERT PROVIDER RETURNING AN ERROR. See Alert.Triggered for further explanation.")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
// Make sure that everything's working as expected after a rough patch
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, false, "")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 2, 0, true, "The alert should have triggered")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, true, "The alert should still be triggered")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 2, false, "The alert should have been resolved")
}
func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Debug: true,
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
URL: "https://twinnation.org/health",
Method: "GET",
},
},
}
config.Set(cfg)
service := &core.Service{
URL: "http://example.com",
Alerts: []*core.Alert{
{
Type: core.CustomAlert,
Enabled: true,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: true,
Triggered: false,
},
},
}
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, true, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, false, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, true, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, false, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
// Make sure that everything's working as expected after a rough patch
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 1, 0, true, "")
HandleAlerting(service, &core.Result{Success: false})
verify(t, service, 2, 0, true, "")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 1, false, "")
HandleAlerting(service, &core.Result{Success: true})
verify(t, service, 0, 2, false, "")
}
func verify(t *testing.T, service *core.Service, expectedNumberOfFailuresInARow, expectedNumberOfSuccessInARow int, expectedTriggered bool, expectedTriggeredReason string) {
if service.NumberOfFailuresInARow != expectedNumberOfFailuresInARow {
t.Fatalf("service.NumberOfFailuresInARow should've been %d, got %d", expectedNumberOfFailuresInARow, service.NumberOfFailuresInARow)
}
if service.NumberOfSuccessesInARow != expectedNumberOfSuccessInARow {
t.Fatalf("service.NumberOfSuccessesInARow should've been %d, got %d", expectedNumberOfSuccessInARow, service.NumberOfSuccessesInARow)
}
if service.Alerts[0].Triggered != expectedTriggered {
if len(expectedTriggeredReason) != 0 {
t.Fatal(expectedTriggeredReason)
} else {
if expectedTriggered {
t.Fatal("The alert should've been triggered")
} else {
t.Fatal("The alert shouldn't have been triggered")
}
}
}
}

View File

@@ -1,7 +1,6 @@
package watchdog
import (
"encoding/json"
"fmt"
"log"
"sync"
@@ -21,10 +20,9 @@ var (
monitoringMutex sync.Mutex
)
// GetServiceStatusesAsJSON returns a list of core.ServiceStatus for each services encoded using json.Marshal.
// GetServiceStatusesAsJSON the JSON encoding of all core.ServiceStatus recorded
func GetServiceStatusesAsJSON() ([]byte, error) {
serviceStatuses := store.GetAll()
return json.Marshal(serviceStatuses)
return store.GetAllAsJSON()
}
// GetUptimeByServiceGroupAndName returns the uptime of a service based on its group and name