Reset
This commit is contained in:
32
config/endpoint/common.go
Normal file
32
config/endpoint/common.go
Normal file
@ -0,0 +1,32 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
|
||||
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
|
||||
|
||||
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
|
||||
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
|
||||
)
|
||||
|
||||
// validateEndpointNameGroupAndAlerts validates the name, group and alerts of an endpoint
|
||||
func validateEndpointNameGroupAndAlerts(name, group string, alerts []*alert.Alert) error {
|
||||
if len(name) == 0 {
|
||||
return ErrEndpointWithNoName
|
||||
}
|
||||
if strings.ContainsAny(name, "\"\\") || strings.ContainsAny(group, "\"\\") {
|
||||
return ErrEndpointWithInvalidNameOrGroup
|
||||
}
|
||||
for _, endpointAlert := range alerts {
|
||||
if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
51
config/endpoint/common_test.go
Normal file
51
config/endpoint/common_test.go
Normal file
@ -0,0 +1,51 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
)
|
||||
|
||||
func TestValidateEndpointNameGroupAndAlerts(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
group string
|
||||
alerts []*alert.Alert
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "n",
|
||||
group: "g",
|
||||
alerts: []*alert.Alert{{Type: "slack"}},
|
||||
},
|
||||
{
|
||||
name: "n",
|
||||
alerts: []*alert.Alert{{Type: "slack"}},
|
||||
},
|
||||
{
|
||||
group: "g",
|
||||
alerts: []*alert.Alert{{Type: "slack"}},
|
||||
expectedErr: ErrEndpointWithNoName,
|
||||
},
|
||||
{
|
||||
name: "\"",
|
||||
alerts: []*alert.Alert{{Type: "slack"}},
|
||||
expectedErr: ErrEndpointWithInvalidNameOrGroup,
|
||||
},
|
||||
{
|
||||
name: "n",
|
||||
group: "\\",
|
||||
alerts: []*alert.Alert{{Type: "slack"}},
|
||||
expectedErr: ErrEndpointWithInvalidNameOrGroup,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := validateEndpointNameGroupAndAlerts(scenario.name, scenario.group, scenario.alerts)
|
||||
if !errors.Is(err, scenario.expectedErr) {
|
||||
t.Errorf("expected error to be %v but got %v", scenario.expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
364
config/endpoint/condition.go
Normal file
364
config/endpoint/condition.go
Normal file
@ -0,0 +1,364 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/jsonpath"
|
||||
"github.com/TwiN/gatus/v5/pattern"
|
||||
)
|
||||
|
||||
// Placeholders
|
||||
const (
|
||||
// StatusPlaceholder is a placeholder for a HTTP status.
|
||||
//
|
||||
// Values that could replace the placeholder: 200, 404, 500, ...
|
||||
StatusPlaceholder = "[STATUS]"
|
||||
|
||||
// IPPlaceholder is a placeholder for an IP.
|
||||
//
|
||||
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
|
||||
IPPlaceholder = "[IP]"
|
||||
|
||||
// DNSRCodePlaceholder is a placeholder for DNS_RCODE
|
||||
//
|
||||
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCodePlaceholder = "[DNS_RCODE]"
|
||||
|
||||
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 1, 500, 1000, ...
|
||||
ResponseTimePlaceholder = "[RESPONSE_TIME]"
|
||||
|
||||
// BodyPlaceholder is a placeholder for the Body of the response
|
||||
//
|
||||
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
|
||||
BodyPlaceholder = "[BODY]"
|
||||
|
||||
// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.
|
||||
//
|
||||
// Values that could replace the placeholder: true, false
|
||||
ConnectedPlaceholder = "[CONNECTED]"
|
||||
|
||||
// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 4461677039 (~52 days)
|
||||
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
|
||||
|
||||
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
|
||||
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
|
||||
)
|
||||
|
||||
// Functions
|
||||
const (
|
||||
// LengthFunctionPrefix is the prefix for the length function
|
||||
//
|
||||
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
|
||||
LengthFunctionPrefix = "len("
|
||||
|
||||
// HasFunctionPrefix is the prefix for the has function
|
||||
//
|
||||
// Usage: has([BODY].errors) == true
|
||||
HasFunctionPrefix = "has("
|
||||
|
||||
// PatternFunctionPrefix is the prefix for the pattern function
|
||||
//
|
||||
// Usage: [IP] == pat(192.168.*.*)
|
||||
PatternFunctionPrefix = "pat("
|
||||
|
||||
// AnyFunctionPrefix is the prefix for the any function
|
||||
//
|
||||
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
|
||||
AnyFunctionPrefix = "any("
|
||||
|
||||
// FunctionSuffix is the suffix for all functions
|
||||
FunctionSuffix = ")"
|
||||
)
|
||||
|
||||
// Other constants
|
||||
const (
|
||||
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
|
||||
InvalidConditionElementSuffix = "(INVALID)"
|
||||
|
||||
// maximumLengthBeforeTruncatingWhenComparedWithPattern is the maximum length an element being compared to a
|
||||
// pattern can have.
|
||||
//
|
||||
// This is only used for aesthetic purposes; it does not influence whether the condition evaluation results in a
|
||||
// success or a failure
|
||||
maximumLengthBeforeTruncatingWhenComparedWithPattern = 25
|
||||
)
|
||||
|
||||
// Condition is a condition that needs to be met in order for an Endpoint to be considered healthy.
|
||||
type Condition string
|
||||
|
||||
// Validate checks if the Condition is valid
|
||||
func (c Condition) Validate() error {
|
||||
r := &Result{}
|
||||
c.evaluate(r, false)
|
||||
if len(r.Errors) != 0 {
|
||||
return errors.New(r.Errors[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluate the Condition with the Result of the health check
|
||||
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bool {
|
||||
condition := string(c)
|
||||
success := false
|
||||
conditionToDisplay := condition
|
||||
if strings.Contains(condition, " == ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " == "), result)
|
||||
success = isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
|
||||
}
|
||||
} else if strings.Contains(condition, " != ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " != "), result)
|
||||
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
|
||||
}
|
||||
} else if strings.Contains(condition, " <= ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " <= "), result)
|
||||
success = resolvedParameters[0] <= resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
|
||||
}
|
||||
} else if strings.Contains(condition, " >= ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " >= "), result)
|
||||
success = resolvedParameters[0] >= resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
|
||||
}
|
||||
} else if strings.Contains(condition, " > ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " > "), result)
|
||||
success = resolvedParameters[0] > resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
|
||||
}
|
||||
} else if strings.Contains(condition, " < ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " < "), result)
|
||||
success = resolvedParameters[0] < resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
|
||||
}
|
||||
} else {
|
||||
result.AddError(fmt.Sprintf("invalid condition: %s", condition))
|
||||
return false
|
||||
}
|
||||
if !success {
|
||||
//logr.Debugf("[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
|
||||
}
|
||||
|
||||
// hasBodyPlaceholder checks whether the condition has a BodyPlaceholder
|
||||
// Used for determining whether the response body should be read or not
|
||||
func (c Condition) hasBodyPlaceholder() bool {
|
||||
return strings.Contains(string(c), BodyPlaceholder)
|
||||
}
|
||||
|
||||
// hasDomainExpirationPlaceholder checks whether the condition has a DomainExpirationPlaceholder
|
||||
// Used for determining whether a whois operation is necessary
|
||||
func (c Condition) hasDomainExpirationPlaceholder() bool {
|
||||
return strings.Contains(string(c), DomainExpirationPlaceholder)
|
||||
}
|
||||
|
||||
// hasIPPlaceholder checks whether the condition has an IPPlaceholder
|
||||
// Used for determining whether an IP lookup is necessary
|
||||
func (c Condition) hasIPPlaceholder() bool {
|
||||
return strings.Contains(string(c), IPPlaceholder)
|
||||
}
|
||||
|
||||
// isEqual compares two strings.
|
||||
//
|
||||
// Supports the "pat" 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// test if inputs are integers
|
||||
firstInt, err1 := strconv.ParseInt(first, 0, 64)
|
||||
secondInt, err2 := strconv.ParseInt(second, 0, 64)
|
||||
if err1 == nil && err2 == nil {
|
||||
return firstInt == secondInt
|
||||
}
|
||||
|
||||
return first == second
|
||||
}
|
||||
|
||||
// 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 i, element := range elements {
|
||||
element = strings.TrimSpace(element)
|
||||
parameters[i] = element
|
||||
switch strings.ToUpper(element) {
|
||||
case StatusPlaceholder:
|
||||
element = strconv.Itoa(result.HTTPStatus)
|
||||
case IPPlaceholder:
|
||||
element = result.IP
|
||||
case ResponseTimePlaceholder:
|
||||
element = strconv.Itoa(int(result.Duration.Milliseconds()))
|
||||
case BodyPlaceholder:
|
||||
element = body
|
||||
case DNSRCodePlaceholder:
|
||||
element = result.DNSRCode
|
||||
case ConnectedPlaceholder:
|
||||
element = strconv.FormatBool(result.Connected)
|
||||
case CertificateExpirationPlaceholder:
|
||||
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
|
||||
case DomainExpirationPlaceholder:
|
||||
element = strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10)
|
||||
default:
|
||||
// if contains the BodyPlaceholder, then evaluate json path
|
||||
if strings.Contains(element, BodyPlaceholder) {
|
||||
checkingForLength := false
|
||||
checkingForExistence := false
|
||||
if strings.HasPrefix(element, LengthFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForLength = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, LengthFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if strings.HasPrefix(element, HasFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForExistence = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, HasFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(strings.TrimPrefix(element, BodyPlaceholder), "."), result.Body)
|
||||
if checkingForExistence {
|
||||
if err != nil {
|
||||
element = "false"
|
||||
} else {
|
||||
element = "true"
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
if checkingForLength {
|
||||
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix
|
||||
} else {
|
||||
element = element + " " + InvalidConditionElementSuffix
|
||||
}
|
||||
} else {
|
||||
if checkingForLength {
|
||||
element = strconv.Itoa(resolvedElementLength)
|
||||
} else {
|
||||
element = resolvedElement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resolvedParameters[i] = element
|
||||
}
|
||||
return parameters, resolvedParameters
|
||||
}
|
||||
|
||||
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 {
|
||||
// If the string is a duration, convert it to milliseconds
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, duration.Milliseconds())
|
||||
} else if number, err := strconv.ParseInt(element, 0, 64); err != nil {
|
||||
// It's not an int, so we'll check if it's a float
|
||||
if f, err := strconv.ParseFloat(element, 64); err == nil {
|
||||
// It's a float, but we'll convert it to an int. We're losing precision here, but it's better than
|
||||
// just returning 0.
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, int64(f))
|
||||
} else {
|
||||
// Default to 0 if the string couldn't be converted to an integer or a float
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, 0)
|
||||
}
|
||||
} else {
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, number)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||
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]
|
||||
}
|
||||
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
|
||||
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
|
||||
}
|
||||
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
|
||||
}
|
||||
// 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]
|
||||
}
|
86
config/endpoint/condition_bench_test.go
Normal file
86
config/endpoint/condition_bench_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package endpoint
|
||||
|
||||
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, false)
|
||||
}
|
||||
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, false)
|
||||
}
|
||||
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, false)
|
||||
}
|
||||
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, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
|
||||
condition := Condition("[BODY].user.name == bob.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
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, false)
|
||||
}
|
||||
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, false)
|
||||
}
|
||||
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, false)
|
||||
}
|
||||
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, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
10
config/endpoint/condition_result.go
Normal file
10
config/endpoint/condition_result.go
Normal file
@ -0,0 +1,10 @@
|
||||
package endpoint
|
||||
|
||||
// ConditionResult result of a Condition
|
||||
type ConditionResult struct {
|
||||
// Condition that was evaluated
|
||||
Condition string `json:"condition"`
|
||||
|
||||
// Success whether the condition was met (successful) or not (failed)
|
||||
Success bool `json:"success"`
|
||||
}
|
779
config/endpoint/condition_test.go
Normal file
779
config/endpoint/condition_test.go
Normal file
@ -0,0 +1,779 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCondition_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
condition Condition
|
||||
expectedErr error
|
||||
}{
|
||||
{condition: "[STATUS] == 200", expectedErr: nil},
|
||||
{condition: "[STATUS] != 200", expectedErr: nil},
|
||||
{condition: "[STATUS] <= 200", expectedErr: nil},
|
||||
{condition: "[STATUS] >= 200", expectedErr: nil},
|
||||
{condition: "[STATUS] < 200", expectedErr: nil},
|
||||
{condition: "[STATUS] > 200", expectedErr: nil},
|
||||
{condition: "[STATUS] == any(200, 201, 202, 203)", expectedErr: nil},
|
||||
{condition: "[STATUS] == [BODY].status", expectedErr: nil},
|
||||
{condition: "[CONNECTED] == true", expectedErr: nil},
|
||||
{condition: "[RESPONSE_TIME] < 500", expectedErr: nil},
|
||||
{condition: "[IP] == 127.0.0.1", expectedErr: nil},
|
||||
{condition: "[BODY] == 1", expectedErr: nil},
|
||||
{condition: "[BODY].test == wat", expectedErr: nil},
|
||||
{condition: "[BODY].test.wat == wat", expectedErr: nil},
|
||||
{condition: "[BODY].age == [BODY].id", expectedErr: nil},
|
||||
{condition: "[BODY].users[0].id == 1", expectedErr: nil},
|
||||
{condition: "len([BODY].users) == 100", expectedErr: nil},
|
||||
{condition: "len([BODY].data) < 5", expectedErr: nil},
|
||||
{condition: "has([BODY].errors) == false", expectedErr: nil},
|
||||
{condition: "has([BODY].users[0].name) == true", expectedErr: nil},
|
||||
{condition: "[BODY].name == pat(john*)", expectedErr: nil},
|
||||
{condition: "[CERTIFICATE_EXPIRATION] > 48h", expectedErr: nil},
|
||||
{condition: "[DOMAIN_EXPIRATION] > 720h", expectedErr: nil},
|
||||
{condition: "raw == raw", expectedErr: nil},
|
||||
{condition: "[STATUS] ? 201", expectedErr: errors.New("invalid condition: [STATUS] ? 201")},
|
||||
{condition: "[STATUS]==201", expectedErr: errors.New("invalid condition: [STATUS]==201")},
|
||||
{condition: "[STATUS] = = 201", expectedErr: errors.New("invalid condition: [STATUS] = = 201")},
|
||||
{condition: "[STATUS] ==", expectedErr: errors.New("invalid condition: [STATUS] ==")},
|
||||
{condition: "[STATUS]", expectedErr: errors.New("invalid condition: [STATUS]")},
|
||||
// FIXME: Should return an error, but doesn't because jsonpath isn't evaluated due to body being empty in Condition.Validate()
|
||||
//{condition: "len([BODY].users == 100", expectedErr: nil},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(string(scenario.condition), func(t *testing.T) {
|
||||
if err := scenario.condition.Validate(); fmt.Sprint(err) != fmt.Sprint(scenario.expectedErr) {
|
||||
t.Errorf("expected err %v, got %v", scenario.expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Condition Condition
|
||||
Result *Result
|
||||
DontResolveFailedConditions bool
|
||||
ExpectedSuccess bool
|
||||
ExpectedOutput string
|
||||
}{
|
||||
{
|
||||
Name: "ip",
|
||||
Condition: Condition("[IP] == 127.0.0.1"),
|
||||
Result: &Result{IP: "127.0.0.1"},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[IP] == 127.0.0.1",
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Condition: Condition("[STATUS] == 200"),
|
||||
Result: &Result{HTTPStatus: 200},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == 200",
|
||||
},
|
||||
{
|
||||
Name: "status-failure",
|
||||
Condition: Condition("[STATUS] == 200"),
|
||||
Result: &Result{HTTPStatus: 500},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (500) == 200",
|
||||
},
|
||||
{
|
||||
Name: "status-using-less-than",
|
||||
Condition: Condition("[STATUS] < 300"),
|
||||
Result: &Result{HTTPStatus: 201},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] < 300",
|
||||
},
|
||||
{
|
||||
Name: "status-using-less-than-failure",
|
||||
Condition: Condition("[STATUS] < 300"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (404) < 300",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than",
|
||||
Condition: Condition("[RESPONSE_TIME] < 500"),
|
||||
Result: &Result{Duration: 50 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] < 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-with-duration",
|
||||
Condition: Condition("[RESPONSE_TIME] < 1s"),
|
||||
Result: &Result{Duration: 50 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] < 1s",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-invalid",
|
||||
Condition: Condition("[RESPONSE_TIME] < potato"),
|
||||
Result: &Result{Duration: 50 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (50) < potato (0)", // Non-numerical values automatically resolve to 0
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than",
|
||||
Condition: Condition("[RESPONSE_TIME] > 500"),
|
||||
Result: &Result{Duration: 750 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] > 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-with-duration",
|
||||
Condition: Condition("[RESPONSE_TIME] > 1s"),
|
||||
Result: &Result{Duration: 2 * time.Second},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] > 1s",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-or-equal-to-equal",
|
||||
Condition: Condition("[RESPONSE_TIME] >= 500"),
|
||||
Result: &Result{Duration: 500 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] >= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-or-equal-to-greater",
|
||||
Condition: Condition("[RESPONSE_TIME] >= 500"),
|
||||
Result: &Result{Duration: 499 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (499) >= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-or-equal-to-failure",
|
||||
Condition: Condition("[RESPONSE_TIME] >= 500"),
|
||||
Result: &Result{Duration: 499 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (499) >= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-or-equal-to-equal",
|
||||
Condition: Condition("[RESPONSE_TIME] <= 500"),
|
||||
Result: &Result{Duration: 500 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] <= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-or-equal-to-less",
|
||||
Condition: Condition("[RESPONSE_TIME] <= 500"),
|
||||
Result: &Result{Duration: 25 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] <= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-or-equal-to-failure",
|
||||
Condition: Condition("[RESPONSE_TIME] <= 500"),
|
||||
Result: &Result{Duration: 750 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (750) <= 500",
|
||||
},
|
||||
{
|
||||
Name: "body",
|
||||
Condition: Condition("[BODY] == test"),
|
||||
Result: &Result{Body: []byte("test")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] == test",
|
||||
},
|
||||
{
|
||||
Name: "body-numerical-equal",
|
||||
Condition: Condition("[BODY] == 123"),
|
||||
Result: &Result{Body: []byte("123")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] == 123",
|
||||
},
|
||||
{
|
||||
Name: "body-numerical-less-than",
|
||||
Condition: Condition("[BODY] < 124"),
|
||||
Result: &Result{Body: []byte("123")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] < 124",
|
||||
},
|
||||
{
|
||||
Name: "body-numerical-greater-than",
|
||||
Condition: Condition("[BODY] > 122"),
|
||||
Result: &Result{Body: []byte("123")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] > 122",
|
||||
},
|
||||
{
|
||||
Name: "body-numerical-greater-than-failure",
|
||||
Condition: Condition("[BODY] > 123"),
|
||||
Result: &Result{Body: []byte("100")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY] (100) > 123",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath",
|
||||
Condition: Condition("[BODY].status == UP"),
|
||||
Result: &Result{Body: []byte("{\"status\":\"UP\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].status == UP",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex",
|
||||
Condition: Condition("[BODY].data.name == john"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.name == john",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-invalid",
|
||||
Condition: Condition("[BODY].data.name == john"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data.name (INVALID) == john",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-len-invalid",
|
||||
Condition: Condition("len([BODY].data.name) == john"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "len([BODY].data.name) (INVALID) == john",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-double-placeholder",
|
||||
Condition: Condition("[BODY].user.firstName != [BODY].user.lastName"),
|
||||
Result: &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].user.firstName != [BODY].user.lastName",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-double-placeholder-failure",
|
||||
Condition: Condition("[BODY].user.firstName == [BODY].user.lastName"),
|
||||
Result: &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].user.firstName (john) == [BODY].user.lastName (doe)",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-when-body-is-array",
|
||||
Condition: Condition("[BODY][0].id == 1"),
|
||||
Result: &Result{Body: []byte("[{\"id\": 1}, {\"id\": 2}]")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY][0].id == 1",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-when-body-has-null-parameter",
|
||||
Condition: Condition("[BODY].data == OK"),
|
||||
Result: &Result{Body: []byte(`{"data": null}"`)},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data (INVALID) == OK",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-when-body-has-array-with-null",
|
||||
Condition: Condition("[BODY].items[0] == OK"),
|
||||
Result: &Result{Body: []byte(`{"items": [null, null]}"`)},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].items[0] (INVALID) == OK",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-when-body-is-null",
|
||||
Condition: Condition("[BODY].data == OK"),
|
||||
Result: &Result{Body: []byte(`null`)},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data (INVALID) == OK",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-when-body-is-array-but-actual-body-is-not",
|
||||
Condition: Condition("[BODY][0].name == test"),
|
||||
Result: &Result{Body: []byte("{\"statusCode\": 500, \"message\": \"Internal Server Error\"}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY][0].name (INVALID) == test",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int",
|
||||
Condition: Condition("[BODY].data.id == 1"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.id == 1",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-array-int",
|
||||
Condition: Condition("[BODY].data[1].id == 2"),
|
||||
Result: &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data[1].id == 2",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-greater-than",
|
||||
Condition: Condition("[BODY].data.id > 0"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.id > 0",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-using-greater-than",
|
||||
Condition: Condition("[BODY].data > 0"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data > 0",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0x1",
|
||||
Condition: Condition("[BODY].data == 1"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 1",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-using-equals",
|
||||
Condition: Condition("[BODY].data == 0x1"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 0x1",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0x2",
|
||||
Condition: Condition("[BODY].data == 2"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0x2\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 2",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0xF",
|
||||
Condition: Condition("[BODY].data == 15"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0xF\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 15",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0xC0ff33",
|
||||
Condition: Condition("[BODY].data == 12648243"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0xC0ff33\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 12648243",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-len",
|
||||
Condition: Condition("len([BODY].data) == 3"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].data) == 3",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-greater",
|
||||
Condition: Condition("[BODY].data >= 1"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0x01\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data >= 1",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-hexadecimal-int-0x01-len",
|
||||
Condition: Condition("len([BODY].data) == 4"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0x01\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].data) == 4",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-octal-int-using-greater-than",
|
||||
Condition: Condition("[BODY].data > 0"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0o1\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data > 0",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-octal-int-using-equal",
|
||||
Condition: Condition("[BODY].data == 2"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0o2\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 2",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-octal-int-using-equals",
|
||||
Condition: Condition("[BODY].data == 0o2"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0o2\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 0o2",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-binary-int-using-greater-than",
|
||||
Condition: Condition("[BODY].data > 0"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0b1\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data > 0",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-binary-int-using-equal",
|
||||
Condition: Condition("[BODY].data == 2"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0b0010\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 2",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-binary-int-using-equals",
|
||||
Condition: Condition("[BODY].data == 0b10"),
|
||||
Result: &Result{Body: []byte("{\"data\": \"0b0010\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == 0b10",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-greater-than-failure",
|
||||
Condition: Condition("[BODY].data.id > 5"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data.id (1) > 5",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-float-using-greater-than-issue433", // As of v5.3.1, Gatus will convert a float to an int. We're losing precision, but it's better than just returning 0
|
||||
Condition: Condition("[BODY].balance > 100"),
|
||||
Result: &Result{Body: []byte(`{"balance": "123.40000000000005"}`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].balance > 100",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-less-than",
|
||||
Condition: Condition("[BODY].data.id < 5"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 2}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.id < 5",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-less-than-failure",
|
||||
Condition: Condition("[BODY].data.id < 5"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"id\": 10}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data.id (10) < 5",
|
||||
},
|
||||
{
|
||||
Name: "connected",
|
||||
Condition: Condition("[CONNECTED] == true"),
|
||||
Result: &Result{Connected: true},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CONNECTED] == true",
|
||||
},
|
||||
{
|
||||
Name: "connected-failure",
|
||||
Condition: Condition("[CONNECTED] == true"),
|
||||
Result: &Result{Connected: false},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CONNECTED] (false) == true",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-not-set",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] == 0"),
|
||||
Result: &Result{},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] == 0",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-numerical",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 60},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] > 2419200000",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-numerical-failure",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-duration",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 12h"),
|
||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] > 12h",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-duration",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
|
||||
},
|
||||
{
|
||||
Name: "no-placeholders",
|
||||
Condition: Condition("1 == 2"),
|
||||
Result: &Result{},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "1 == 2",
|
||||
},
|
||||
///////////////
|
||||
// Functions //
|
||||
///////////////
|
||||
// len
|
||||
{
|
||||
Name: "len-body-jsonpath-complex",
|
||||
Condition: Condition("len([BODY].data.name) == 4"),
|
||||
Result: &Result{Body: []byte("{\"data\": {\"name\": \"john\"}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].data.name) == 4",
|
||||
},
|
||||
{
|
||||
Name: "len-body-array",
|
||||
Condition: Condition("len([BODY]) == 3"),
|
||||
Result: &Result{Body: []byte("[{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY]) == 3",
|
||||
},
|
||||
{
|
||||
Name: "len-body-keyed-array",
|
||||
Condition: Condition("len([BODY].data) == 3"),
|
||||
Result: &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].data) == 3",
|
||||
},
|
||||
{
|
||||
Name: "len-body-array-invalid",
|
||||
Condition: Condition("len([BODY].data) == 8"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "len([BODY].data) (INVALID) == 8",
|
||||
},
|
||||
{
|
||||
Name: "len-body-string",
|
||||
Condition: Condition("len([BODY]) == 8"),
|
||||
Result: &Result{Body: []byte("john.doe")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY]) == 8",
|
||||
},
|
||||
{
|
||||
Name: "len-body-keyed-string",
|
||||
Condition: Condition("len([BODY].name) == 8"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].name) == 8",
|
||||
},
|
||||
{
|
||||
Name: "len-body-keyed-int",
|
||||
Condition: Condition("len([BODY].age) == 2"),
|
||||
Result: &Result{Body: []byte(`{"age":18}`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].age) == 2",
|
||||
},
|
||||
{
|
||||
Name: "len-body-keyed-bool",
|
||||
Condition: Condition("len([BODY].adult) == 4"),
|
||||
Result: &Result{Body: []byte(`{"adult":true}`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].adult) == 4",
|
||||
},
|
||||
{
|
||||
Name: "len-body-object-inside-array",
|
||||
Condition: Condition("len([BODY][0]) == 23"),
|
||||
Result: &Result{Body: []byte(`[{"age":18,"adult":true}]`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY][0]) == 23",
|
||||
},
|
||||
{
|
||||
Name: "len-body-object-keyed-int-inside-array",
|
||||
Condition: Condition("len([BODY][0].age) == 2"),
|
||||
Result: &Result{Body: []byte(`[{"age":18,"adult":true}]`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY][0].age) == 2",
|
||||
},
|
||||
{
|
||||
Name: "len-body-keyed-bool-inside-array",
|
||||
Condition: Condition("len([BODY][0].adult) == 4"),
|
||||
Result: &Result{Body: []byte(`[{"age":18,"adult":true}]`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY][0].adult) == 4",
|
||||
},
|
||||
{
|
||||
Name: "len-body-object",
|
||||
Condition: Condition("len([BODY]) == 20"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY]) == 20",
|
||||
},
|
||||
// pat
|
||||
{
|
||||
Name: "pat-body-1",
|
||||
Condition: Condition("[BODY] == pat(*john*)"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] == pat(*john*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-body-2",
|
||||
Condition: Condition("[BODY].name == pat(john*)"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].name == pat(john*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-body-failure",
|
||||
Condition: Condition("[BODY].name == pat(bob*)"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].name (john.doe) == pat(bob*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-body-html",
|
||||
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
|
||||
Result: &Result{Body: []byte(`<!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>`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] == pat(*<div id=\"user\">john.doe</div>*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-body-html-failure",
|
||||
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
|
||||
Result: &Result{Body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY] (<!DOCTYPE html><html lang...(truncated)) == pat(*<div id=\"user\">john.doe</div>*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-body-html-failure-alt",
|
||||
Condition: Condition("pat(*<div id=\"user\">john.doe</div>*) == [BODY]"),
|
||||
Result: &Result{Body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "pat(*<div id=\"user\">john.doe</div>*) == [BODY] (<!DOCTYPE html><html lang...(truncated))",
|
||||
},
|
||||
{
|
||||
Name: "pat-body-in-array",
|
||||
Condition: Condition("[BODY].data == pat(*Whatever*)"),
|
||||
Result: &Result{Body: []byte("{\"data\": [\"hello\", \"world\", \"Whatever\"]}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data == pat(*Whatever*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-ip",
|
||||
Condition: Condition("[IP] == pat(10.*)"),
|
||||
Result: &Result{IP: "10.0.0.0"},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[IP] == pat(10.*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-ip-failure",
|
||||
Condition: Condition("[IP] == pat(10.*)"),
|
||||
Result: &Result{IP: "255.255.255.255"},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[IP] (255.255.255.255) == pat(10.*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-status",
|
||||
Condition: Condition("[STATUS] == pat(4*)"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == pat(4*)",
|
||||
},
|
||||
{
|
||||
Name: "pat-status-failure",
|
||||
Condition: Condition("[STATUS] == pat(4*)"),
|
||||
Result: &Result{HTTPStatus: 200},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (200) == pat(4*)",
|
||||
},
|
||||
// any
|
||||
{
|
||||
Name: "any-body-1",
|
||||
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
|
||||
},
|
||||
{
|
||||
Name: "any-body-2",
|
||||
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"jane.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
|
||||
},
|
||||
{
|
||||
Name: "any-body-failure",
|
||||
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
|
||||
Result: &Result{Body: []byte("{\"name\": \"bob\"}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].name (bob) == any(john.doe, jane.doe)",
|
||||
},
|
||||
{
|
||||
Name: "any-status-1",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 200},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "any-status-2",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 429},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "any-status-reverse",
|
||||
Condition: Condition("any(200, 429) == [STATUS]"),
|
||||
Result: &Result{HTTPStatus: 429},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "any(200, 429) == [STATUS]",
|
||||
},
|
||||
{
|
||||
Name: "any-status-failure",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (404) == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "any-status-failure-but-dont-resolve",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
DontResolveFailedConditions: true,
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] == any(200, 429)",
|
||||
},
|
||||
// has
|
||||
{
|
||||
Name: "has",
|
||||
Condition: Condition("has([BODY].errors) == false"),
|
||||
Result: &Result{Body: []byte("{}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "has([BODY].errors) == false",
|
||||
},
|
||||
{
|
||||
Name: "has-key-of-map",
|
||||
Condition: Condition("has([BODY].article) == true"),
|
||||
Result: &Result{Body: []byte("{\n \"article\": {\n \"id\": 123,\n \"title\": \"Hello, world!\",\n \"author\": \"John Doe\",\n \"tags\": [\"hello\", \"world\"],\n \"content\": \"I really like Gatus!\"\n }\n}")},
|
||||
DontResolveFailedConditions: false,
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "has([BODY].article) == true",
|
||||
},
|
||||
{
|
||||
Name: "has-failure",
|
||||
Condition: Condition("has([BODY].errors) == false"),
|
||||
Result: &Result{Body: []byte("{\"errors\": [\"1\"]}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "has([BODY].errors) (true) == false",
|
||||
},
|
||||
{
|
||||
Name: "has-failure-but-dont-resolve",
|
||||
Condition: Condition("has([BODY].errors) == false"),
|
||||
Result: &Result{Body: []byte("{\"errors\": [\"1\"]}")},
|
||||
DontResolveFailedConditions: true,
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "has([BODY].errors) == false",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions)
|
||||
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
|
||||
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
|
||||
}
|
||||
if scenario.Result.ConditionResults[0].Condition != scenario.ExpectedOutput {
|
||||
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", scenario.Condition, scenario.ExpectedOutput, scenario.Result.ConditionResults[0].Condition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
condition := Condition("[STATUS] ? 201")
|
||||
result := &Result{HTTPStatus: 201}
|
||||
condition.evaluate(result, false)
|
||||
if result.Success {
|
||||
t.Error("condition was invalid, result should've been a failure")
|
||||
}
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("condition was invalid, result should've had an error")
|
||||
}
|
||||
}
|
38
config/endpoint/dns/dns.go
Normal file
38
config/endpoint/dns/dns.go
Normal file
@ -0,0 +1,38 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name
|
||||
ErrDNSWithNoQueryName = errors.New("you must specify a query name in the DNS configuration")
|
||||
|
||||
// ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type
|
||||
ErrDNSWithInvalidQueryType = errors.New("invalid query type in the DNS configuration")
|
||||
)
|
||||
|
||||
// Config for an Endpoint of type DNS
|
||||
type Config struct {
|
||||
// QueryType is the type for the DNS records like A, AAAA, CNAME...
|
||||
QueryType string `yaml:"query-type"`
|
||||
|
||||
// QueryName is the query for DNS
|
||||
QueryName string `yaml:"query-name"`
|
||||
}
|
||||
|
||||
func (d *Config) ValidateAndSetDefault() error {
|
||||
if len(d.QueryName) == 0 {
|
||||
return ErrDNSWithNoQueryName
|
||||
}
|
||||
if !strings.HasSuffix(d.QueryName, ".") {
|
||||
d.QueryName += "."
|
||||
}
|
||||
if _, ok := dns.StringToType[d.QueryType]; !ok {
|
||||
return ErrDNSWithInvalidQueryType
|
||||
}
|
||||
return nil
|
||||
}
|
27
config/endpoint/dns/dns_test.go
Normal file
27
config/endpoint/dns/dns_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefault(t *testing.T) {
|
||||
dns := &Config{
|
||||
QueryType: "A",
|
||||
QueryName: "",
|
||||
}
|
||||
err := dns.ValidateAndSetDefault()
|
||||
if err == nil {
|
||||
t.Error("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
|
||||
dns := &Config{
|
||||
QueryType: "B",
|
||||
QueryName: "example.com",
|
||||
}
|
||||
err := dns.ValidateAndSetDefault()
|
||||
if err == nil {
|
||||
t.Error("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
|
||||
}
|
||||
}
|
479
config/endpoint/endpoint.go
Normal file
479
config/endpoint/endpoint.go
Normal file
@ -0,0 +1,479 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
// HostHeader is the name of the header used to specify the host
|
||||
HostHeader = "Host"
|
||||
|
||||
// ContentTypeHeader is the name of the header used to specify the content type
|
||||
ContentTypeHeader = "Content-Type"
|
||||
|
||||
// UserAgentHeader is the name of the header used to specify the request's user agent
|
||||
UserAgentHeader = "User-Agent"
|
||||
|
||||
// GatusUserAgent is the default user agent that Gatus uses to send requests.
|
||||
GatusUserAgent = "Gatus/1.0"
|
||||
|
||||
TypeDNS Type = "DNS"
|
||||
TypeTCP Type = "TCP"
|
||||
TypeSCTP Type = "SCTP"
|
||||
TypeUDP Type = "UDP"
|
||||
TypeICMP Type = "ICMP"
|
||||
TypeSTARTTLS Type = "STARTTLS"
|
||||
TypeTLS Type = "TLS"
|
||||
TypeHTTP Type = "HTTP"
|
||||
TypeWS Type = "WEBSOCKET"
|
||||
TypeSSH Type = "SSH"
|
||||
TypeUNKNOWN Type = "UNKNOWN"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEndpointWithNoCondition is the error with which Gatus will panic if an endpoint is configured with no conditions
|
||||
ErrEndpointWithNoCondition = errors.New("you must specify at least one condition per endpoint")
|
||||
|
||||
// ErrEndpointWithNoURL is the error with which Gatus will panic if an endpoint is configured with no url
|
||||
ErrEndpointWithNoURL = errors.New("you must specify an url for each endpoint")
|
||||
|
||||
// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type
|
||||
ErrUnknownEndpointType = errors.New("unknown endpoint type")
|
||||
|
||||
// ErrInvalidConditionFormat is the error with which Gatus will panic if a condition has an invalid format
|
||||
ErrInvalidConditionFormat = errors.New("invalid condition format: does not match '<VALUE> <COMPARATOR> <VALUE>'")
|
||||
|
||||
// ErrInvalidEndpointIntervalForDomainExpirationPlaceholder is the error with which Gatus will panic if an endpoint
|
||||
// has both an interval smaller than 5 minutes and a condition with DomainExpirationPlaceholder.
|
||||
// This is because the free whois service we are using should not be abused, especially considering the fact that
|
||||
// the data takes a while to be updated.
|
||||
ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)")
|
||||
)
|
||||
|
||||
// Endpoint is the configuration of a service to be monitored
|
||||
type Endpoint struct {
|
||||
// Enabled defines whether to enable the monitoring of the endpoint
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
|
||||
// Name of the endpoint. Can be anything.
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
|
||||
Group string `yaml:"group,omitempty"`
|
||||
|
||||
// URL to send the request to
|
||||
URL string `yaml:"url"`
|
||||
|
||||
// Method of the request made to the url of the endpoint
|
||||
Method string `yaml:"method,omitempty"`
|
||||
|
||||
// Body of the request
|
||||
Body string `yaml:"body,omitempty"`
|
||||
|
||||
// GraphQL is whether to wrap the body in a query param ({"query":"$body"})
|
||||
GraphQL bool `yaml:"graphql,omitempty"`
|
||||
|
||||
// Headers of the request
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
|
||||
// Interval is the duration to wait between every status check
|
||||
Interval time.Duration `yaml:"interval,omitempty"`
|
||||
|
||||
// Conditions used to determine the health of the endpoint
|
||||
Conditions []Condition `yaml:"conditions"`
|
||||
|
||||
// Alerts is the alerting configuration for the endpoint in case of failure
|
||||
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
|
||||
|
||||
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
|
||||
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
|
||||
|
||||
// DNSConfig is the configuration for DNS monitoring
|
||||
DNSConfig *dns.Config `yaml:"dns,omitempty"`
|
||||
|
||||
// SSH is the configuration for SSH monitoring
|
||||
SSHConfig *sshconfig.Config `yaml:"ssh,omitempty"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the endpoint's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// UIConfig is the configuration for the UI
|
||||
UIConfig *ui.Config `yaml:"ui,omitempty"`
|
||||
|
||||
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
|
||||
NumberOfFailuresInARow int `yaml:"-"`
|
||||
|
||||
// NumberOfSuccessesInARow is the number of successful evaluations in a row
|
||||
NumberOfSuccessesInARow int `yaml:"-"`
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the endpoint is enabled or not
|
||||
func (e *Endpoint) IsEnabled() bool {
|
||||
if e.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *e.Enabled
|
||||
}
|
||||
|
||||
// Type returns the endpoint type
|
||||
func (e *Endpoint) Type() Type {
|
||||
switch {
|
||||
case e.DNSConfig != nil:
|
||||
return TypeDNS
|
||||
case strings.HasPrefix(e.URL, "tcp://"):
|
||||
return TypeTCP
|
||||
case strings.HasPrefix(e.URL, "sctp://"):
|
||||
return TypeSCTP
|
||||
case strings.HasPrefix(e.URL, "udp://"):
|
||||
return TypeUDP
|
||||
case strings.HasPrefix(e.URL, "icmp://"):
|
||||
return TypeICMP
|
||||
case strings.HasPrefix(e.URL, "starttls://"):
|
||||
return TypeSTARTTLS
|
||||
case strings.HasPrefix(e.URL, "tls://"):
|
||||
return TypeTLS
|
||||
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
|
||||
return TypeHTTP
|
||||
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
|
||||
return TypeWS
|
||||
case strings.HasPrefix(e.URL, "ssh://"):
|
||||
return TypeSSH
|
||||
default:
|
||||
return TypeUNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
|
||||
func (e *Endpoint) ValidateAndSetDefaults() error {
|
||||
if err := validateEndpointNameGroupAndAlerts(e.Name, e.Group, e.Alerts); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(e.URL) == 0 {
|
||||
return ErrEndpointWithNoURL
|
||||
}
|
||||
if e.ClientConfig == nil {
|
||||
e.ClientConfig = client.GetDefaultConfig()
|
||||
} else {
|
||||
if err := e.ClientConfig.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if e.UIConfig == nil {
|
||||
e.UIConfig = ui.GetDefaultConfig()
|
||||
} else {
|
||||
if err := e.UIConfig.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if e.Interval == 0 {
|
||||
e.Interval = 1 * time.Minute
|
||||
}
|
||||
if len(e.Method) == 0 {
|
||||
e.Method = http.MethodGet
|
||||
}
|
||||
if len(e.Headers) == 0 {
|
||||
e.Headers = make(map[string]string)
|
||||
}
|
||||
// Automatically add user agent header if there isn't one specified in the endpoint configuration
|
||||
if _, userAgentHeaderExists := e.Headers[UserAgentHeader]; !userAgentHeaderExists {
|
||||
e.Headers[UserAgentHeader] = GatusUserAgent
|
||||
}
|
||||
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
|
||||
// and endpoint.GraphQL is set to true
|
||||
if _, contentTypeHeaderExists := e.Headers[ContentTypeHeader]; !contentTypeHeaderExists && e.GraphQL {
|
||||
e.Headers[ContentTypeHeader] = "application/json"
|
||||
}
|
||||
if len(e.Conditions) == 0 {
|
||||
return ErrEndpointWithNoCondition
|
||||
}
|
||||
for _, c := range e.Conditions {
|
||||
if e.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {
|
||||
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
|
||||
}
|
||||
}
|
||||
if e.DNSConfig != nil {
|
||||
return e.DNSConfig.ValidateAndSetDefault()
|
||||
}
|
||||
if e.SSHConfig != nil {
|
||||
return e.SSHConfig.Validate()
|
||||
}
|
||||
if e.Type() == TypeUNKNOWN {
|
||||
return ErrUnknownEndpointType
|
||||
}
|
||||
for _, maintenanceWindow := range e.MaintenanceWindows {
|
||||
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Make sure that the request can be created
|
||||
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
|
||||
func (e *Endpoint) DisplayName() string {
|
||||
if len(e.Group) > 0 {
|
||||
return e.Group + "/" + e.Name
|
||||
}
|
||||
return e.Name
|
||||
}
|
||||
|
||||
// Key returns the unique key for the Endpoint
|
||||
func (e *Endpoint) Key() string {
|
||||
return ConvertGroupAndEndpointNameToKey(e.Group, e.Name)
|
||||
}
|
||||
|
||||
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors
|
||||
// on configuration reload.
|
||||
// More context on https://github.com/TwiN/gatus/issues/536
|
||||
func (e *Endpoint) Close() {
|
||||
if e.Type() == TypeHTTP {
|
||||
client.GetHTTPClient(e.ClientConfig).CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
|
||||
func (e *Endpoint) EvaluateHealth() *Result {
|
||||
result := &Result{Success: true, Errors: []string{}}
|
||||
// Parse or extract hostname from URL
|
||||
if e.DNSConfig != nil {
|
||||
result.Hostname = strings.TrimSuffix(e.URL, ":53")
|
||||
} else {
|
||||
urlObject, err := url.Parse(e.URL)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
} else {
|
||||
result.Hostname = urlObject.Hostname()
|
||||
result.port = urlObject.Port()
|
||||
}
|
||||
}
|
||||
// Retrieve IP if necessary
|
||||
if e.needsToRetrieveIP() {
|
||||
e.getIP(result)
|
||||
}
|
||||
// Retrieve domain expiration if necessary
|
||||
if e.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
|
||||
var err error
|
||||
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
}
|
||||
// Call the endpoint (if there's no errors)
|
||||
if len(result.Errors) == 0 {
|
||||
e.call(result)
|
||||
} else {
|
||||
result.Success = false
|
||||
}
|
||||
// Evaluate the conditions
|
||||
for _, condition := range e.Conditions {
|
||||
success := condition.evaluate(result, e.UIConfig.DontResolveFailedConditions)
|
||||
if !success {
|
||||
result.Success = false
|
||||
}
|
||||
}
|
||||
result.Timestamp = time.Now()
|
||||
// Clean up parameters that we don't need to keep in the results
|
||||
if e.UIConfig.HideURL {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, e.URL, "<redacted>")
|
||||
}
|
||||
}
|
||||
if e.UIConfig.HideHostname {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
|
||||
}
|
||||
result.Hostname = "" // remove it from the result so it doesn't get exposed
|
||||
}
|
||||
if e.UIConfig.HidePort && len(result.port) > 0 {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "<redacted>")
|
||||
}
|
||||
result.port = ""
|
||||
}
|
||||
if e.UIConfig.HideConditions {
|
||||
result.ConditionResults = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *Endpoint) getIP(result *Result) {
|
||||
if ips, err := net.LookupIP(result.Hostname); err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
} else {
|
||||
result.IP = ips[0].String()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Endpoint) call(result *Result) {
|
||||
var request *http.Request
|
||||
var response *http.Response
|
||||
var err error
|
||||
var certificate *x509.Certificate
|
||||
endpointType := e.Type()
|
||||
if endpointType == TypeHTTP {
|
||||
request = e.buildHTTPRequest()
|
||||
}
|
||||
startTime := time.Now()
|
||||
if endpointType == TypeDNS {
|
||||
result.Connected, result.DNSRCode, result.Body, err = client.QueryDNS(e.DNSConfig.QueryType, e.DNSConfig.QueryName, e.URL)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSTARTTLS || endpointType == TypeTLS {
|
||||
if endpointType == TypeSTARTTLS {
|
||||
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
|
||||
} else {
|
||||
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig)
|
||||
}
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
} else if endpointType == TypeTCP {
|
||||
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeUDP {
|
||||
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSCTP {
|
||||
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeICMP {
|
||||
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
|
||||
} else if endpointType == TypeWS {
|
||||
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSSH {
|
||||
// If there's no username/password specified, attempt to validate just the SSH banner
|
||||
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
|
||||
result.Connected, result.HTTPStatus, err =
|
||||
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Success = result.Connected
|
||||
result.Duration = time.Since(startTime)
|
||||
return
|
||||
}
|
||||
var cli *ssh.Client
|
||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
} else {
|
||||
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
|
||||
result.Duration = time.Since(startTime)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
|
||||
certificate = response.TLS.PeerCertificates[0]
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
}
|
||||
result.HTTPStatus = response.StatusCode
|
||||
result.Connected = response.StatusCode > 0
|
||||
// Only read the Body if there's a condition that uses the BodyPlaceholder
|
||||
if e.needsToReadBody() {
|
||||
result.Body, err = io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
result.AddError("error reading response body:" + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Endpoint) buildHTTPRequest() *http.Request {
|
||||
var bodyBuffer *bytes.Buffer
|
||||
if e.GraphQL {
|
||||
graphQlBody := map[string]string{
|
||||
"query": e.Body,
|
||||
}
|
||||
body, _ := json.Marshal(graphQlBody)
|
||||
bodyBuffer = bytes.NewBuffer(body)
|
||||
} else {
|
||||
bodyBuffer = bytes.NewBuffer([]byte(e.Body))
|
||||
}
|
||||
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
|
||||
for k, v := range e.Headers {
|
||||
request.Header.Set(k, v)
|
||||
if k == HostHeader {
|
||||
request.Host = v
|
||||
}
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// needsToReadBody checks if there's any condition that requires the response Body to be read
|
||||
func (e *Endpoint) needsToReadBody() bool {
|
||||
for _, condition := range e.Conditions {
|
||||
if condition.hasBodyPlaceholder() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
|
||||
func (e *Endpoint) needsToRetrieveDomainExpiration() bool {
|
||||
for _, condition := range e.Conditions {
|
||||
if condition.hasDomainExpirationPlaceholder() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// needsToRetrieveIP checks if there's any condition that requires an IP lookup
|
||||
func (e *Endpoint) needsToRetrieveIP() bool {
|
||||
for _, condition := range e.Conditions {
|
||||
if condition.hasIPPlaceholder() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
934
config/endpoint/endpoint_test.go
Normal file
934
config/endpoint/endpoint_test.go
Normal file
@ -0,0 +1,934 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestEndpoint(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Endpoint Endpoint
|
||||
ExpectedResult *Result
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
}{
|
||||
{
|
||||
Name: "success",
|
||||
Endpoint: Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP", "[CERTIFICATE_EXPIRATION] > 24h"},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: true,
|
||||
Connected: true,
|
||||
Hostname: "twin.sh",
|
||||
ConditionResults: []*ConditionResult{
|
||||
{Condition: "[STATUS] == 200", Success: true},
|
||||
{Condition: "[BODY].status == UP", Success: true},
|
||||
{Condition: "[CERTIFICATE_EXPIRATION] > 24h", Success: true},
|
||||
},
|
||||
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
},
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`)),
|
||||
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(9999 * time.Hour)}}},
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "failed-body-condition",
|
||||
Endpoint: Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: false,
|
||||
Connected: true,
|
||||
Hostname: "twin.sh",
|
||||
ConditionResults: []*ConditionResult{
|
||||
{Condition: "[STATUS] == 200", Success: true},
|
||||
{Condition: "[BODY].status (DOWN) == UP", Success: false},
|
||||
},
|
||||
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
},
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "DOWN"}`))}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "failed-status-condition",
|
||||
Endpoint: Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[STATUS] == 200"},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: false,
|
||||
Connected: true,
|
||||
Hostname: "twin.sh",
|
||||
ConditionResults: []*ConditionResult{
|
||||
{Condition: "[STATUS] (502) == 200", Success: false},
|
||||
},
|
||||
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
},
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "failed-status-condition-with-hidden-conditions",
|
||||
Endpoint: Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[STATUS] == 200"},
|
||||
UIConfig: &ui.Config{HideConditions: true},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: false,
|
||||
Connected: true,
|
||||
Hostname: "twin.sh",
|
||||
ConditionResults: []*ConditionResult{}, // Because UIConfig.HideConditions is true, the condition results should not be shown.
|
||||
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
},
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "condition-with-failed-certificate-expiration",
|
||||
Endpoint: Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[CERTIFICATE_EXPIRATION] > 100h"},
|
||||
UIConfig: &ui.Config{DontResolveFailedConditions: true},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: false,
|
||||
Connected: true,
|
||||
Hostname: "twin.sh",
|
||||
ConditionResults: []*ConditionResult{
|
||||
// Because UIConfig.DontResolveFailedConditions is true, the values in the condition should not be resolved
|
||||
{Condition: "[CERTIFICATE_EXPIRATION] > 100h", Success: false},
|
||||
},
|
||||
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
},
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: http.NoBody,
|
||||
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(5 * time.Hour)}}},
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "domain-expiration",
|
||||
Endpoint: Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"},
|
||||
Interval: 5 * time.Minute,
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: true,
|
||||
Connected: true,
|
||||
Hostname: "twin.sh",
|
||||
ConditionResults: []*ConditionResult{
|
||||
{Condition: "[DOMAIN_EXPIRATION] > 100h", Success: true},
|
||||
},
|
||||
DomainExpiration: 999999 * time.Hour, // Note that this test only checks if it's non-zero.
|
||||
},
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "endpoint-that-will-time-out-and-hidden-hostname",
|
||||
Endpoint: Endpoint{
|
||||
Name: "endpoint-that-will-time-out",
|
||||
URL: "https://twin.sh:9999/health",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
UIConfig: &ui.Config{HideHostname: true, HidePort: true},
|
||||
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: false,
|
||||
Connected: false,
|
||||
Hostname: "", // Because Endpoint.UIConfig.HideHostname is true, this should be empty.
|
||||
ConditionResults: []*ConditionResult{
|
||||
{Condition: "[CONNECTED] (false) == true", Success: false},
|
||||
},
|
||||
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
DomainExpiration: 0,
|
||||
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.
|
||||
Errors: []string{`Get "https://<redacted>:<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
|
||||
},
|
||||
MockRoundTripper: nil,
|
||||
},
|
||||
{
|
||||
Name: "endpoint-that-will-time-out-and-hidden-url",
|
||||
Endpoint: Endpoint{
|
||||
Name: "endpoint-that-will-time-out",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
UIConfig: &ui.Config{HideURL: true},
|
||||
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
Success: false,
|
||||
Connected: false,
|
||||
Hostname: "twin.sh",
|
||||
ConditionResults: []*ConditionResult{
|
||||
{Condition: "[CONNECTED] (false) == true", Success: false},
|
||||
},
|
||||
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
DomainExpiration: 0,
|
||||
// Because Endpoint.UIConfig.HideURL is true, the URL should be replaced by <redacted>.
|
||||
Errors: []string{`Get "<redacted>": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
|
||||
},
|
||||
MockRoundTripper: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if scenario.MockRoundTripper != nil {
|
||||
mockClient := &http.Client{Transport: scenario.MockRoundTripper}
|
||||
if scenario.Endpoint.ClientConfig != nil && scenario.Endpoint.ClientConfig.Timeout > 0 {
|
||||
mockClient.Timeout = scenario.Endpoint.ClientConfig.Timeout
|
||||
}
|
||||
client.InjectHTTPClient(mockClient)
|
||||
} else {
|
||||
client.InjectHTTPClient(nil)
|
||||
}
|
||||
err := scenario.Endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Error("did not expect an error, got", err)
|
||||
}
|
||||
result := scenario.Endpoint.EvaluateHealth()
|
||||
if result.Success != scenario.ExpectedResult.Success {
|
||||
t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success)
|
||||
}
|
||||
if result.Connected != scenario.ExpectedResult.Connected {
|
||||
t.Errorf("Expected connected to be %v, got %v", scenario.ExpectedResult.Connected, result.Connected)
|
||||
}
|
||||
if result.Hostname != scenario.ExpectedResult.Hostname {
|
||||
t.Errorf("Expected hostname to be %v, got %v", scenario.ExpectedResult.Hostname, result.Hostname)
|
||||
}
|
||||
if len(result.ConditionResults) != len(scenario.ExpectedResult.ConditionResults) {
|
||||
t.Errorf("Expected %v condition results, got %v", len(scenario.ExpectedResult.ConditionResults), len(result.ConditionResults))
|
||||
} else {
|
||||
for i, conditionResult := range result.ConditionResults {
|
||||
if conditionResult.Condition != scenario.ExpectedResult.ConditionResults[i].Condition {
|
||||
t.Errorf("Expected condition to be %v, got %v", scenario.ExpectedResult.ConditionResults[i].Condition, conditionResult.Condition)
|
||||
}
|
||||
if conditionResult.Success != scenario.ExpectedResult.ConditionResults[i].Success {
|
||||
t.Errorf("Expected success of condition '%s' to be %v, got %v", conditionResult.Condition, scenario.ExpectedResult.ConditionResults[i].Success, conditionResult.Success)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result.Errors) != len(scenario.ExpectedResult.Errors) {
|
||||
t.Errorf("Expected %v errors, got %v", len(scenario.ExpectedResult.Errors), len(result.Errors))
|
||||
} else {
|
||||
for i, err := range result.Errors {
|
||||
if err != scenario.ExpectedResult.Errors[i] {
|
||||
t.Errorf("Expected error to be %v, got %v", scenario.ExpectedResult.Errors[i], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.DomainExpiration != scenario.ExpectedResult.DomainExpiration {
|
||||
// Note that DomainExpiration is only resolved if there's a condition with the DomainExpirationPlaceholder in it.
|
||||
// In other words, if there's no condition with [DOMAIN_EXPIRATION] in it, the DomainExpiration field will be 0.
|
||||
// Because this is a live call, mocking it would be too much of a pain, so we're just going to check if
|
||||
// the actual value is non-zero when the expected result is non-zero.
|
||||
if scenario.ExpectedResult.DomainExpiration.Hours() > 0 && !(result.DomainExpiration.Hours() > 0) {
|
||||
t.Errorf("Expected domain expiration to be non-zero, got %v", result.DomainExpiration)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_IsEnabled(t *testing.T) {
|
||||
if !(&Endpoint{Enabled: nil}).IsEnabled() {
|
||||
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
|
||||
}
|
||||
if value := false; (&Endpoint{Enabled: &value}).IsEnabled() {
|
||||
t.Error("endpoint.IsEnabled() should've returned false, because Enabled was set to false")
|
||||
}
|
||||
if value := true; !(&Endpoint{Enabled: &value}).IsEnabled() {
|
||||
t.Error("Endpoint.IsEnabled() should've returned true, because Enabled was set to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_Type(t *testing.T) {
|
||||
type args struct {
|
||||
URL string
|
||||
DNS *dns.Config
|
||||
SSH *ssh.Config
|
||||
}
|
||||
tests := []struct {
|
||||
args args
|
||||
want Type
|
||||
}{
|
||||
{
|
||||
args: args{
|
||||
URL: "8.8.8.8",
|
||||
DNS: &dns.Config{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com",
|
||||
},
|
||||
},
|
||||
want: TypeDNS,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "tcp://127.0.0.1:6379",
|
||||
},
|
||||
want: TypeTCP,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "icmp://example.com",
|
||||
},
|
||||
want: TypeICMP,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "sctp://example.com",
|
||||
},
|
||||
want: TypeSCTP,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "udp://example.com",
|
||||
},
|
||||
want: TypeUDP,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "starttls://smtp.gmail.com:587",
|
||||
},
|
||||
want: TypeSTARTTLS,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "tls://example.com:443",
|
||||
},
|
||||
want: TypeTLS,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "https://twin.sh/health",
|
||||
},
|
||||
want: TypeHTTP,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "wss://example.com/",
|
||||
},
|
||||
want: TypeWS,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "ws://example.com/",
|
||||
},
|
||||
want: TypeWS,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "ssh://example.com:22",
|
||||
SSH: &ssh.Config{
|
||||
Username: "root",
|
||||
Password: "password",
|
||||
},
|
||||
},
|
||||
want: TypeSSH,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "invalid://example.org",
|
||||
},
|
||||
want: TypeUNKNOWN,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "no-scheme",
|
||||
},
|
||||
want: TypeUNKNOWN,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.want), func(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
URL: tt.args.URL,
|
||||
DNSConfig: tt.args.DNS,
|
||||
}
|
||||
if got := endpoint.Type(); got != tt.want {
|
||||
t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
MaintenanceWindows: []*maintenance.Config{{Start: "03:50", Duration: 4 * time.Hour}},
|
||||
}
|
||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
if endpoint.ClientConfig == nil {
|
||||
t.Error("client configuration should've been set to the default configuration")
|
||||
} else {
|
||||
if endpoint.ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
|
||||
t.Errorf("Default client configuration should've set Insecure to %v, got %v", client.GetDefaultConfig().Insecure, endpoint.ClientConfig.Insecure)
|
||||
}
|
||||
if endpoint.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
|
||||
t.Errorf("Default client configuration should've set IgnoreRedirect to %v, got %v", client.GetDefaultConfig().IgnoreRedirect, endpoint.ClientConfig.IgnoreRedirect)
|
||||
}
|
||||
if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
|
||||
t.Errorf("Default client configuration should've set Timeout to %v, got %v", client.GetDefaultConfig().Timeout, endpoint.ClientConfig.Timeout)
|
||||
}
|
||||
}
|
||||
if endpoint.Method != "GET" {
|
||||
t.Error("Endpoint method should've defaulted to GET")
|
||||
}
|
||||
if endpoint.Interval != time.Minute {
|
||||
t.Error("Endpoint interval should've defaulted to 1 minute")
|
||||
}
|
||||
if endpoint.Headers == nil {
|
||||
t.Error("Endpoint headers should've defaulted to an empty map")
|
||||
}
|
||||
if len(endpoint.Alerts) != 1 {
|
||||
t.Error("Endpoint should've had 1 alert")
|
||||
}
|
||||
if !endpoint.Alerts[0].IsEnabled() {
|
||||
t.Error("Endpoint alert should've defaulted to true")
|
||||
}
|
||||
if endpoint.Alerts[0].SuccessThreshold != 2 {
|
||||
t.Error("Endpoint alert should've defaulted to a success threshold of 2")
|
||||
}
|
||||
if endpoint.Alerts[0].FailureThreshold != 3 {
|
||||
t.Error("Endpoint alert should've defaulted to a failure threshold of 3")
|
||||
}
|
||||
if len(endpoint.MaintenanceWindows) != 1 {
|
||||
t.Error("Endpoint should've had 1 maintenance window")
|
||||
}
|
||||
if !endpoint.MaintenanceWindows[0].IsEnabled() {
|
||||
t.Error("Endpoint maintenance should've defaulted to true")
|
||||
}
|
||||
if endpoint.MaintenanceWindows[0].Timezone != "UTC" {
|
||||
t.Error("Endpoint maintenance should've defaulted to UTC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "invalid-condition",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{"[STATUS] invalid 200"},
|
||||
}
|
||||
if err := endpoint.ValidateAndSetDefaults(); err == nil {
|
||||
t.Error("endpoint validation should've returned an error, but didn't")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
ClientConfig: &client.Config{
|
||||
Insecure: true,
|
||||
IgnoreRedirect: true,
|
||||
Timeout: 0,
|
||||
},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
if endpoint.ClientConfig == nil {
|
||||
t.Error("client configuration should've been set to the default configuration")
|
||||
} else {
|
||||
if !endpoint.ClientConfig.Insecure {
|
||||
t.Error("endpoint.ClientConfig.Insecure should've been set to true")
|
||||
}
|
||||
if !endpoint.ClientConfig.IgnoreRedirect {
|
||||
t.Error("endpoint.ClientConfig.IgnoreRedirect should've been set to true")
|
||||
}
|
||||
if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
|
||||
t.Error("endpoint.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
|
||||
endpoint := &Endpoint{
|
||||
Name: "dns-test",
|
||||
URL: "https://example.com",
|
||||
DNSConfig: &dns.Config{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com",
|
||||
},
|
||||
Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Error("did not expect an error, got", err)
|
||||
}
|
||||
if endpoint.DNSConfig.QueryName != "example.com." {
|
||||
t.Error("Endpoint.dns.query-name should be formatted with . suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "fail when has no user",
|
||||
username: "",
|
||||
password: "password",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||
},
|
||||
{
|
||||
name: "fail when has no password",
|
||||
username: "username",
|
||||
password: "",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
|
||||
},
|
||||
{
|
||||
name: "success when all fields are set",
|
||||
username: "username",
|
||||
password: "password",
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
endpoint := &Endpoint{
|
||||
Name: "ssh-test",
|
||||
URL: "https://example.com",
|
||||
SSHConfig: &ssh.Config{
|
||||
Username: scenario.username,
|
||||
Password: scenario.password,
|
||||
},
|
||||
Conditions: []Condition{Condition("[STATUS] == 0")},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if !errors.Is(err, scenario.expectedErr) {
|
||||
t.Errorf("expected error %v, got %v", scenario.expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
endpoint *Endpoint
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "",
|
||||
URL: "https://example.com",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
},
|
||||
expectedErr: ErrEndpointWithNoName,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "endpoint-with-no-url",
|
||||
URL: "",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
},
|
||||
expectedErr: ErrEndpointWithNoURL,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "endpoint-with-no-conditions",
|
||||
URL: "https://example.com",
|
||||
Conditions: nil,
|
||||
},
|
||||
expectedErr: ErrEndpointWithNoCondition,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "domain-expiration-with-bad-interval",
|
||||
URL: "https://example.com",
|
||||
Interval: time.Minute,
|
||||
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
|
||||
},
|
||||
expectedErr: ErrInvalidEndpointIntervalForDomainExpirationPlaceholder,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "domain-expiration-with-good-interval",
|
||||
URL: "https://example.com",
|
||||
Interval: 5 * time.Minute,
|
||||
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.endpoint.Name, func(t *testing.T) {
|
||||
if err := scenario.endpoint.ValidateAndSetDefaults(); err != scenario.expectedErr {
|
||||
t.Errorf("Expected error %v, got %v", scenario.expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_buildHTTPRequest(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{condition},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
request := endpoint.buildHTTPRequest()
|
||||
if request.Method != "GET" {
|
||||
t.Error("request.Method should've been GET, but was", request.Method)
|
||||
}
|
||||
if request.Host != "twin.sh" {
|
||||
t.Error("request.Host should've been twin.sh, but was", request.Host)
|
||||
}
|
||||
if userAgent := request.Header.Get("User-Agent"); userAgent != GatusUserAgent {
|
||||
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", GatusUserAgent, userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{condition},
|
||||
Headers: map[string]string{
|
||||
"User-Agent": "Test/2.0",
|
||||
},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
request := endpoint.buildHTTPRequest()
|
||||
if request.Method != "GET" {
|
||||
t.Error("request.Method should've been GET, but was", request.Method)
|
||||
}
|
||||
if request.Host != "twin.sh" {
|
||||
t.Error("request.Host should've been twin.sh, but was", request.Host)
|
||||
}
|
||||
if userAgent := request.Header.Get("User-Agent"); userAgent != "Test/2.0" {
|
||||
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", "Test/2.0", userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Method: "POST",
|
||||
Conditions: []Condition{condition},
|
||||
Headers: map[string]string{
|
||||
"Host": "example.com",
|
||||
},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
request := endpoint.buildHTTPRequest()
|
||||
if request.Method != "POST" {
|
||||
t.Error("request.Method should've been POST, but was", request.Method)
|
||||
}
|
||||
if request.Host != "example.com" {
|
||||
t.Error("request.Host should've been example.com, but was", request.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := Endpoint{
|
||||
Name: "website-graphql",
|
||||
URL: "https://twin.sh/graphql",
|
||||
Method: "POST",
|
||||
Conditions: []Condition{condition},
|
||||
GraphQL: true,
|
||||
Body: `{
|
||||
users(gender: "female") {
|
||||
id
|
||||
name
|
||||
gender
|
||||
avatar
|
||||
}
|
||||
}`,
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
request := endpoint.buildHTTPRequest()
|
||||
if request.Method != "POST" {
|
||||
t.Error("request.Method should've been POST, but was", request.Method)
|
||||
}
|
||||
if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" {
|
||||
t.Error("request.Header.Content-Type should've been application/json, but was", contentType)
|
||||
}
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if !strings.HasPrefix(string(body), "{\"query\":") {
|
||||
t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
bodyCondition := Condition("[BODY].status == UP")
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{condition, bodyCondition},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
result := endpoint.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
if !result.Connected {
|
||||
t.Error("Because the connection has been established, result.Connected should've been true")
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("Because all conditions passed, this should have been a success")
|
||||
}
|
||||
if result.Hostname != "twin.sh" {
|
||||
t.Error("result.Hostname should've been twin.sh, but was", result.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "invalid-url",
|
||||
URL: "https://httpstat.us/200?sleep=100",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
ClientConfig: &client.Config{
|
||||
Timeout: 1 * time.Millisecond,
|
||||
},
|
||||
UIConfig: &ui.Config{
|
||||
HideURL: true,
|
||||
},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
result := endpoint.EvaluateHealth()
|
||||
if result.Success {
|
||||
t.Error("Because one of the conditions was invalid, result.Success should have been false")
|
||||
}
|
||||
if len(result.Errors) == 0 {
|
||||
t.Error("There should've been an error")
|
||||
}
|
||||
if !strings.Contains(result.Errors[0], "<redacted>") || strings.Contains(result.Errors[0], endpoint.URL) {
|
||||
t.Error("result.Errors[0] should've had the URL redacted because ui.hide-url is set to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
|
||||
conditionBody := Condition("[BODY] == pat(*.*.*.*)")
|
||||
endpoint := Endpoint{
|
||||
Name: "example",
|
||||
URL: "8.8.8.8",
|
||||
DNSConfig: &dns.Config{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com.",
|
||||
},
|
||||
Conditions: []Condition{conditionSuccess, conditionBody},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
result := endpoint.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody)
|
||||
}
|
||||
if !result.Connected {
|
||||
t.Error("Because the connection has been established, result.Connected should've been true")
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("Because all conditions passed, this should have been a success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
endpoint Endpoint
|
||||
conditions []Condition
|
||||
success bool
|
||||
}{
|
||||
{
|
||||
name: "ssh-success",
|
||||
endpoint: Endpoint{
|
||||
Name: "ssh-success",
|
||||
URL: "ssh://localhost",
|
||||
SSHConfig: &ssh.Config{
|
||||
Username: "scenario",
|
||||
Password: "scenario",
|
||||
},
|
||||
Body: "{ \"command\": \"uptime\" }",
|
||||
},
|
||||
conditions: []Condition{Condition("[STATUS] == 0")},
|
||||
success: true,
|
||||
},
|
||||
{
|
||||
name: "ssh-failure",
|
||||
endpoint: Endpoint{
|
||||
Name: "ssh-failure",
|
||||
URL: "ssh://localhost",
|
||||
SSHConfig: &ssh.Config{
|
||||
Username: "scenario",
|
||||
Password: "scenario",
|
||||
},
|
||||
Body: "{ \"command\": \"uptime\" }",
|
||||
},
|
||||
conditions: []Condition{Condition("[STATUS] == 1")},
|
||||
success: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
scenario.endpoint.ValidateAndSetDefaults()
|
||||
scenario.endpoint.Conditions = scenario.conditions
|
||||
result := scenario.endpoint.EvaluateHealth()
|
||||
if result.Success != scenario.success {
|
||||
t.Errorf("Expected success to be %v, but was %v", scenario.success, result.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "icmp-test",
|
||||
URL: "icmp://127.0.0.1",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatal("did not expect an error, got", err)
|
||||
}
|
||||
result := endpoint.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0])
|
||||
}
|
||||
if !result.Connected {
|
||||
t.Error("Because the connection has been established, result.Connected should've been true")
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("Because all conditions passed, this should have been a success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_DisplayName(t *testing.T) {
|
||||
if endpoint := (Endpoint{Name: "n"}); endpoint.DisplayName() != "n" {
|
||||
t.Error("endpoint.DisplayName() should've been 'n', but was", endpoint.DisplayName())
|
||||
}
|
||||
if endpoint := (Endpoint{Group: "g", Name: "n"}); endpoint.DisplayName() != "g/n" {
|
||||
t.Error("endpoint.DisplayName() should've been 'g/n', but was", endpoint.DisplayName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_getIP(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "invalid-url-test",
|
||||
URL: "",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
}
|
||||
result := &Result{}
|
||||
endpoint.getIP(result)
|
||||
if len(result.Errors) == 0 {
|
||||
t.Error("endpoint.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_needsToReadBody(t *testing.T) {
|
||||
statusCondition := Condition("[STATUS] == 200")
|
||||
bodyCondition := Condition("[BODY].status == UP")
|
||||
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
|
||||
if (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected false, got true")
|
||||
}
|
||||
if !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Endpoint{Conditions: []Condition{statusCondition, bodyCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
|
||||
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveDomainExpiration() {
|
||||
t.Error("expected false, got true")
|
||||
}
|
||||
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[DOMAIN_EXPIRATION] < 720h"}}).needsToRetrieveDomainExpiration() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_needsToRetrieveIP(t *testing.T) {
|
||||
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveIP() {
|
||||
t.Error("expected false, got true")
|
||||
}
|
||||
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[IP] == 127.0.0.1"}}).needsToRetrieveIP() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
39
config/endpoint/event.go
Normal file
39
config/endpoint/event.go
Normal file
@ -0,0 +1,39 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event is something that happens at a specific time
|
||||
type Event struct {
|
||||
// Type is the kind of event
|
||||
Type EventType `json:"type"`
|
||||
|
||||
// Timestamp is the moment at which the event happened
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EventType is, uh, the types of events?
|
||||
type EventType string
|
||||
|
||||
var (
|
||||
// EventStart is a type of event that represents when an endpoint starts being monitored
|
||||
EventStart EventType = "START"
|
||||
|
||||
// EventHealthy is a type of event that represents an endpoint passing all of its conditions
|
||||
EventHealthy EventType = "HEALTHY"
|
||||
|
||||
// EventUnhealthy is a type of event that represents an endpoint failing one or more of its conditions
|
||||
EventUnhealthy EventType = "UNHEALTHY"
|
||||
)
|
||||
|
||||
// NewEventFromResult creates an Event from a Result
|
||||
func NewEventFromResult(result *Result) *Event {
|
||||
event := &Event{Timestamp: result.Timestamp}
|
||||
if result.Success {
|
||||
event.Type = EventHealthy
|
||||
} else {
|
||||
event.Type = EventUnhealthy
|
||||
}
|
||||
return event
|
||||
}
|
14
config/endpoint/event_test.go
Normal file
14
config/endpoint/event_test.go
Normal file
@ -0,0 +1,14 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewEventFromResult(t *testing.T) {
|
||||
if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy {
|
||||
t.Error("expected event.Type to be EventHealthy")
|
||||
}
|
||||
if event := NewEventFromResult(&Result{Success: false}); event.Type != EventUnhealthy {
|
||||
t.Error("expected event.Type to be EventUnhealthy")
|
||||
}
|
||||
}
|
83
config/endpoint/external_endpoint.go
Normal file
83
config/endpoint/external_endpoint.go
Normal file
@ -0,0 +1,83 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token.
|
||||
ErrExternalEndpointWithNoToken = errors.New("you must specify a token for each external endpoint")
|
||||
)
|
||||
|
||||
// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that
|
||||
// said endpoints are not monitored by Gatus itself; Gatus only displays their results and takes
|
||||
// care of alerting
|
||||
type ExternalEndpoint struct {
|
||||
// Enabled defines whether to enable the monitoring of the endpoint
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
|
||||
// Name of the endpoint. Can be anything.
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
|
||||
Group string `yaml:"group,omitempty"`
|
||||
|
||||
// Token is the bearer token that must be provided through the Authorization header to push results to the endpoint
|
||||
Token string `yaml:"token,omitempty"`
|
||||
|
||||
// Alerts is the alerting configuration for the endpoint in case of failure
|
||||
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
|
||||
|
||||
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
|
||||
NumberOfFailuresInARow int `yaml:"-"`
|
||||
|
||||
// NumberOfSuccessesInARow is the number of successful evaluations in a row
|
||||
NumberOfSuccessesInARow int `yaml:"-"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the ExternalEndpoint and sets the default values
|
||||
func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
|
||||
if err := validateEndpointNameGroupAndAlerts(externalEndpoint.Name, externalEndpoint.Group, externalEndpoint.Alerts); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(externalEndpoint.Token) == 0 {
|
||||
return ErrExternalEndpointWithNoToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the endpoint is enabled or not
|
||||
func (externalEndpoint *ExternalEndpoint) IsEnabled() bool {
|
||||
if externalEndpoint.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *externalEndpoint.Enabled
|
||||
}
|
||||
|
||||
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
|
||||
func (externalEndpoint *ExternalEndpoint) DisplayName() string {
|
||||
if len(externalEndpoint.Group) > 0 {
|
||||
return externalEndpoint.Group + "/" + externalEndpoint.Name
|
||||
}
|
||||
return externalEndpoint.Name
|
||||
}
|
||||
|
||||
// Key returns the unique key for the Endpoint
|
||||
func (externalEndpoint *ExternalEndpoint) Key() string {
|
||||
return ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name)
|
||||
}
|
||||
|
||||
// ToEndpoint converts the ExternalEndpoint to an Endpoint
|
||||
func (externalEndpoint *ExternalEndpoint) ToEndpoint() *Endpoint {
|
||||
endpoint := &Endpoint{
|
||||
Enabled: externalEndpoint.Enabled,
|
||||
Name: externalEndpoint.Name,
|
||||
Group: externalEndpoint.Group,
|
||||
Alerts: externalEndpoint.Alerts,
|
||||
NumberOfFailuresInARow: externalEndpoint.NumberOfFailuresInARow,
|
||||
NumberOfSuccessesInARow: externalEndpoint.NumberOfSuccessesInARow,
|
||||
}
|
||||
return endpoint
|
||||
}
|
25
config/endpoint/external_endpoint_test.go
Normal file
25
config/endpoint/external_endpoint_test.go
Normal file
@ -0,0 +1,25 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExternalEndpoint_ToEndpoint(t *testing.T) {
|
||||
externalEndpoint := &ExternalEndpoint{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
}
|
||||
convertedEndpoint := externalEndpoint.ToEndpoint()
|
||||
if externalEndpoint.Name != convertedEndpoint.Name {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Name, convertedEndpoint.Name)
|
||||
}
|
||||
if externalEndpoint.Group != convertedEndpoint.Group {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Group, convertedEndpoint.Group)
|
||||
}
|
||||
if externalEndpoint.Key() != convertedEndpoint.Key() {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Key(), convertedEndpoint.Key())
|
||||
}
|
||||
if externalEndpoint.DisplayName() != convertedEndpoint.DisplayName() {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.DisplayName(), convertedEndpoint.DisplayName())
|
||||
}
|
||||
}
|
19
config/endpoint/key.go
Normal file
19
config/endpoint/key.go
Normal file
@ -0,0 +1,19 @@
|
||||
package endpoint
|
||||
|
||||
import "strings"
|
||||
|
||||
// ConvertGroupAndEndpointNameToKey converts a group and an endpoint to a key
|
||||
func ConvertGroupAndEndpointNameToKey(groupName, endpointName string) string {
|
||||
return sanitize(groupName) + "_" + sanitize(endpointName)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
s = strings.ReplaceAll(s, "/", "-")
|
||||
s = strings.ReplaceAll(s, "_", "-")
|
||||
s = strings.ReplaceAll(s, ".", "-")
|
||||
s = strings.ReplaceAll(s, ",", "-")
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
s = strings.ReplaceAll(s, "#", "-")
|
||||
return s
|
||||
}
|
11
config/endpoint/key_bench_test.go
Normal file
11
config/endpoint/key_bench_test.go
Normal file
@ -0,0 +1,11 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkConvertGroupAndEndpointNameToKey(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
ConvertGroupAndEndpointNameToKey("group", "name")
|
||||
}
|
||||
}
|
36
config/endpoint/key_test.go
Normal file
36
config/endpoint/key_test.go
Normal file
@ -0,0 +1,36 @@
|
||||
package endpoint
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConvertGroupAndEndpointNameToKey(t *testing.T) {
|
||||
type Scenario struct {
|
||||
GroupName string
|
||||
EndpointName string
|
||||
ExpectedOutput string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
GroupName: "Core",
|
||||
EndpointName: "Front End",
|
||||
ExpectedOutput: "core_front-end",
|
||||
},
|
||||
{
|
||||
GroupName: "Load balancers",
|
||||
EndpointName: "us-west-2",
|
||||
ExpectedOutput: "load-balancers_us-west-2",
|
||||
},
|
||||
{
|
||||
GroupName: "a/b test",
|
||||
EndpointName: "a",
|
||||
ExpectedOutput: "a-b-test_a",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
|
||||
output := ConvertGroupAndEndpointNameToKey(scenario.GroupName, scenario.EndpointName)
|
||||
if output != scenario.ExpectedOutput {
|
||||
t.Errorf("expected '%s', got '%s'", scenario.ExpectedOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
69
config/endpoint/result.go
Normal file
69
config/endpoint/result.go
Normal file
@ -0,0 +1,69 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result of the evaluation of a Endpoint
|
||||
type Result struct {
|
||||
// HTTPStatus is the HTTP response status code
|
||||
HTTPStatus int `json:"status,omitempty"`
|
||||
|
||||
// DNSRCode is the response code of a DNS query in a human-readable format
|
||||
//
|
||||
// Possible values: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCode string `json:"-"`
|
||||
|
||||
// Hostname extracted from Endpoint.URL
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
|
||||
// IP resolved from the Endpoint URL
|
||||
IP string `json:"-"`
|
||||
|
||||
// Connected whether a connection to the host was established successfully
|
||||
Connected bool `json:"-"`
|
||||
|
||||
// Duration time that the request took
|
||||
Duration time.Duration `json:"duration"`
|
||||
|
||||
// Errors encountered during the evaluation of the Endpoint's health
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
|
||||
// ConditionResults are the results of each of the Endpoint's Condition
|
||||
ConditionResults []*ConditionResult `json:"conditionResults,omitempty"`
|
||||
|
||||
// Success whether the result signifies a success or not
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Timestamp when the request was sent
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// CertificateExpiration is the duration before the certificate expires
|
||||
CertificateExpiration time.Duration `json:"-"`
|
||||
|
||||
// DomainExpiration is the duration before the domain expires
|
||||
DomainExpiration time.Duration `json:"-"`
|
||||
|
||||
// Body is the response body
|
||||
//
|
||||
// Note that this field is not persisted in the storage.
|
||||
// It is used for health evaluation as well as debugging purposes.
|
||||
Body []byte `json:"-"`
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Below is used only for the UI and is not persisted in the storage //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
port string `yaml:"-"` // used for endpoints[].ui.hide-port
|
||||
}
|
||||
|
||||
// AddError adds an error to the result's list of errors.
|
||||
// It also ensures that there are no duplicates.
|
||||
func (r *Result) AddError(error string) {
|
||||
for _, resultError := range r.Errors {
|
||||
if resultError == error {
|
||||
// If the error already exists, don't add it
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Errors = append(r.Errors, error)
|
||||
}
|
21
config/endpoint/result_test.go
Normal file
21
config/endpoint/result_test.go
Normal file
@ -0,0 +1,21 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResult_AddError(t *testing.T) {
|
||||
result := &Result{}
|
||||
result.AddError("potato")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("should've had 1 error")
|
||||
}
|
||||
result.AddError("potato")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("should've still had 1 error, because a duplicate error was added")
|
||||
}
|
||||
result.AddError("tomato")
|
||||
if len(result.Errors) != 2 {
|
||||
t.Error("should've had 2 error")
|
||||
}
|
||||
}
|
33
config/endpoint/ssh/ssh.go
Normal file
33
config/endpoint/ssh/ssh.go
Normal file
@ -0,0 +1,33 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
|
||||
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
|
||||
|
||||
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
|
||||
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Username string `yaml:"username,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
}
|
||||
|
||||
// Validate the SSH configuration
|
||||
func (cfg *Config) Validate() error {
|
||||
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
|
||||
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(cfg.Username) == 0 {
|
||||
return ErrEndpointWithoutSSHUsername
|
||||
}
|
||||
if len(cfg.Password) == 0 {
|
||||
return ErrEndpointWithoutSSHPassword
|
||||
}
|
||||
return nil
|
||||
}
|
23
config/endpoint/ssh/ssh_test.go
Normal file
23
config/endpoint/ssh/ssh_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSSH_validate(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Error("didn't expect an error")
|
||||
}
|
||||
cfg.Username = "username"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("expected an error")
|
||||
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
|
||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
|
||||
}
|
||||
cfg.Password = "password"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("expected no error, got '%v'", err)
|
||||
}
|
||||
}
|
38
config/endpoint/status.go
Normal file
38
config/endpoint/status.go
Normal file
@ -0,0 +1,38 @@
|
||||
package endpoint
|
||||
|
||||
// Status contains the evaluation Results of an Endpoint
|
||||
type Status struct {
|
||||
// Name of the endpoint
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Key of the Endpoint
|
||||
Key string `json:"key"`
|
||||
|
||||
// Results is the list of endpoint evaluation results
|
||||
Results []*Result `json:"results"`
|
||||
|
||||
// Events is a list of events
|
||||
Events []*Event `json:"events,omitempty"`
|
||||
|
||||
// Uptime information on the endpoint's uptime
|
||||
//
|
||||
// Used by the memory store.
|
||||
//
|
||||
// To retrieve the uptime between two time, use store.GetUptimeByKey.
|
||||
Uptime *Uptime `json:"-"`
|
||||
}
|
||||
|
||||
// NewStatus creates a new Status
|
||||
func NewStatus(group, name string) *Status {
|
||||
return &Status{
|
||||
Name: name,
|
||||
Group: group,
|
||||
Key: ConvertGroupAndEndpointNameToKey(group, name),
|
||||
Results: make([]*Result, 0),
|
||||
Events: make([]*Event, 0),
|
||||
Uptime: NewUptime(),
|
||||
}
|
||||
}
|
19
config/endpoint/status_test.go
Normal file
19
config/endpoint/status_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewEndpointStatus(t *testing.T) {
|
||||
ep := &Endpoint{Name: "name", Group: "group"}
|
||||
status := NewStatus(ep.Group, ep.Name)
|
||||
if status.Name != ep.Name {
|
||||
t.Errorf("expected %s, got %s", ep.Name, status.Name)
|
||||
}
|
||||
if status.Group != ep.Group {
|
||||
t.Errorf("expected %s, got %s", ep.Group, status.Group)
|
||||
}
|
||||
if status.Key != "group_name" {
|
||||
t.Errorf("expected %s, got %s", "group_name", status.Key)
|
||||
}
|
||||
}
|
69
config/endpoint/ui/ui.go
Normal file
69
config/endpoint/ui/ui.go
Normal file
@ -0,0 +1,69 @@
|
||||
package ui
|
||||
|
||||
import "errors"
|
||||
|
||||
// Config is the UI configuration for endpoint.Endpoint
|
||||
type Config struct {
|
||||
// HideConditions whether to hide the condition results on the UI
|
||||
HideConditions bool `yaml:"hide-conditions"`
|
||||
|
||||
// HideHostname whether to hide the hostname in the Result
|
||||
HideHostname bool `yaml:"hide-hostname"`
|
||||
|
||||
// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
|
||||
HideURL bool `yaml:"hide-url"`
|
||||
|
||||
// HidePort whether to hide the port in the Result
|
||||
HidePort bool `yaml:"hide-port"`
|
||||
|
||||
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
|
||||
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
|
||||
|
||||
// Badge is the configuration for the badges generated
|
||||
Badge *Badge `yaml:"badge"`
|
||||
}
|
||||
|
||||
type Badge struct {
|
||||
ResponseTime *ResponseTime `yaml:"response-time"`
|
||||
}
|
||||
|
||||
type ResponseTime struct {
|
||||
Thresholds []int `yaml:"thresholds"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidBadgeResponseTimeConfig = errors.New("invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values")
|
||||
)
|
||||
|
||||
// ValidateAndSetDefaults validates the UI configuration and sets the default values
|
||||
func (config *Config) ValidateAndSetDefaults() error {
|
||||
if config.Badge != nil {
|
||||
if len(config.Badge.ResponseTime.Thresholds) != 5 {
|
||||
return ErrInvalidBadgeResponseTimeConfig
|
||||
}
|
||||
for i := 4; i > 0; i-- {
|
||||
if config.Badge.ResponseTime.Thresholds[i] < config.Badge.ResponseTime.Thresholds[i-1] {
|
||||
return ErrInvalidBadgeResponseTimeConfig
|
||||
}
|
||||
}
|
||||
} else {
|
||||
config.Badge = GetDefaultConfig().Badge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultConfig retrieves the default UI configuration
|
||||
func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
HideHostname: false,
|
||||
HideURL: false,
|
||||
HidePort: false,
|
||||
DontResolveFailedConditions: false,
|
||||
HideConditions: false,
|
||||
Badge: &Badge{
|
||||
ResponseTime: &ResponseTime{
|
||||
Thresholds: []int{50, 200, 300, 500, 750},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
53
config/endpoint/ui/ui_test.go
Normal file
53
config/endpoint/ui/ui_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "with-valid-config",
|
||||
config: &Config{
|
||||
Badge: &Badge{
|
||||
ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500, 750}},
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "with-invalid-threshold-length",
|
||||
config: &Config{
|
||||
Badge: &Badge{
|
||||
ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500}},
|
||||
},
|
||||
},
|
||||
wantErr: ErrInvalidBadgeResponseTimeConfig,
|
||||
},
|
||||
{
|
||||
name: "with-invalid-thresholds-order",
|
||||
config: &Config{
|
||||
Badge: &Badge{ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 500, 300, 750}}},
|
||||
},
|
||||
wantErr: ErrInvalidBadgeResponseTimeConfig,
|
||||
},
|
||||
{
|
||||
name: "with-no-badge-configured", // should give default badge cfg
|
||||
config: &Config{},
|
||||
wantErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.config.ValidateAndSetDefaults(); !errors.Is(err, tt.wantErr) {
|
||||
t.Errorf("Expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
24
config/endpoint/uptime.go
Normal file
24
config/endpoint/uptime.go
Normal file
@ -0,0 +1,24 @@
|
||||
package endpoint
|
||||
|
||||
// Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself
|
||||
// and some other statistics
|
||||
type Uptime struct {
|
||||
// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)
|
||||
//
|
||||
// Used only if the storage type is memory
|
||||
HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"`
|
||||
}
|
||||
|
||||
// HourlyUptimeStatistics is a struct containing all metrics collected over the course of an hour
|
||||
type HourlyUptimeStatistics struct {
|
||||
TotalExecutions uint64 // Total number of checks
|
||||
SuccessfulExecutions uint64 // Number of successful executions
|
||||
TotalExecutionsResponseTime uint64 // Total response time for all executions in milliseconds
|
||||
}
|
||||
|
||||
// NewUptime creates a new Uptime
|
||||
func NewUptime() *Uptime {
|
||||
return &Uptime{
|
||||
HourlyStatistics: make(map[int64]*HourlyUptimeStatistics),
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user