Compare commits

...

10 Commits

Author SHA1 Message Date
TwinProduction
b4d81a9661 Tables are cuter 2020-10-01 20:04:04 -04:00
TwinProduction
81c59f02d8 Close #16: Update documentation 2020-10-01 19:57:52 -04:00
TwinProduction
8101646ba5 Work on #16: Support patterns 2020-10-01 19:57:11 -04:00
TwinProduction
18d3236586 Make resolved/triggered alert logs more obvious 2020-09-29 20:06:47 -04:00
TwinProduction
bc16ae1794 Add tests for comparing two placeholders 2020-09-29 19:33:06 -04:00
Chris C
d019942278 Update README.md 2020-09-28 12:50:22 -04:00
TwinProduction
8c5551f93b Update build.yml 2020-09-27 20:39:42 -04:00
TwinProduction
3b15feaf7d Update README.md 2020-09-26 15:21:06 -04:00
TwinProduction
326ad2c80d Update README.md 2020-09-26 15:18:52 -04:00
TwinProduction
b4b9f5c1be Improve documentation 2020-09-26 15:15:50 -04:00
14 changed files with 227 additions and 26 deletions

View File

@@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Set up Go 1.14
- name: Set up Go 1.15
uses: actions/setup-go@v1
with:
go-version: 1.14
go-version: 1.15
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
uses: actions/checkout@v2
- name: Build binary to make sure it works
run: go build -mod vendor
- name: Test

View File

@@ -1,13 +1,13 @@
![Gatus](static/logo-with-name.png)
![build](https://github.com/TwinProduction/gatus/workflows/build/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwinProduction/gatus)](https://goreportcard.com/report/github.com/TwinProduction/gatus)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwinProduction/gatus?)](https://goreportcard.com/report/github.com/TwinProduction/gatus)
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
A service health dashboard in Go that is meant to be used as a docker
image with a custom configuration file.
I personally deploy it in my Kubernetes cluster and have it monitor the status of my
I personally deploy it in my Kubernetes cluster and let it monitor the status of my
core applications: https://status.twinnation.org/
@@ -17,6 +17,8 @@ core applications: https://status.twinnation.org/
- [Usage](#usage)
- [Configuration](#configuration)
- [Conditions](#conditions)
- [Placeholders](#placeholders)
- [Functions](#functions)
- [Alerting](#alerting)
- [Configuring Slack alerts](#configuring-slack-alerts)
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
@@ -75,7 +77,7 @@ Note that you can also add environment variables in the configuration file (i.e.
### Configuration
| Parameter | Description | Default |
| ---------------------------------------- | ----------------------------------------------------------------------------- | -------------- |
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `debug` | Whether to enable debug logs | `false` |
| `metrics` | Whether to expose metrics at /metrics | `false` |
| `services` | List of services to monitor | Required `[]` |
@@ -114,23 +116,46 @@ Note that you can also add environment variables in the configuration file (i.e.
Here are some examples of conditions you can use:
| Condition | Description | Passing values | Failing values |
| -----------------------------| ------------------------------------------------------- | -------------------------- | -------------- |
|:-----------------------------|:------------------------------------------------------- |:-------------------------- | -------------- |
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... |
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... |
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... |
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 400, 200, ... |
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 501ms |
| `[BODY] == 1` | The body must be equal to 1 | 1 | Anything else |
| `[IP] == 127.0.0.1` | Target IP must be 127.0.0.1 | 127.0.0.1 | 0.0.0.0 |
| `[BODY] == 1` | The body must be equal to 1 | 1 | `{}`, `2`, ... |
| `[BODY].user.name == john` | JSONPath value of `$.user.name` is equal to `john` | `{"user":{"name":"john"}}` | |
| `[BODY].data[0].id == 1` | JSONPath value of `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | |
| `[BODY].age == [BODY].id` | JSONPath value of `$.age` is equal JSONPath `$.id` | `{"age":1,"id":1}` | |
| `len([BODY].data) < 5` | Array at JSONPath `$.data` has less than 5 elements | `{"data":[{"id":1}]}` | |
| `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
#### Placeholders
| Placeholder | Description | Example of resolved value |
|:---------------------------- |:------------------------------------------------------- |:-------------------------- |
| `[STATUS]` | Resolves into the HTTP status of the request | 404
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | 10
| `[IP]` | Resolves into the IP of the target host | 192.168.0.232
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}`
#### Functions
| Function | Description | Example |
|:-----------|:---------------------------------------------------------------------------------------------------------------- |:-------------------------- |
| `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8`
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)`
**NOTE**: Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
### Alerting
#### Configuring Slack alerts
```yaml
@@ -139,8 +164,8 @@ alerting:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
services:
- name: twinnation
interval: 30s
url: "https://twinnation.org/health"
interval: 30s
alerts:
- type: slack
enabled: true
@@ -175,15 +200,15 @@ alerting:
integration-key: "********************************"
services:
- name: twinnation
interval: 30s
url: "https://twinnation.org/health"
interval: 30s
alerts:
- type: pagerduty
enabled: true
failure-threshold: 3
success-threshold: 5
description: "healthcheck failed 3 times in a row"
send-on-resolved: true
description: "healthcheck failed 3 times in a row"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
@@ -208,8 +233,8 @@ services:
- type: twilio
enabled: true
failure-threshold: 5
description: "healthcheck failed 5 times in a row"
send-on-resolved: true
description: "healthcheck failed 5 times in a row"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
@@ -245,8 +270,8 @@ alerting:
}
services:
- name: twinnation
interval: 30s
url: "https://twinnation.org/health"
interval: 30s
alerts:
- type: custom
enabled: true
@@ -267,12 +292,12 @@ Other than using one of the examples provided in the `examples` folder, you can
creating a configuration file - we'll call it `config.yaml` for this example - and running the following
command:
```
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/test.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
```
If you're on Windows, replace `"$(pwd)"` by the absolute path to your current directory, e.g.:
```
docker run -p 8080:8080 --mount type=bind,source=E:/Go/src/github.com/TwinProduction/gatus/test.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
```
## Running the tests

View File

@@ -10,6 +10,8 @@ import (
"strings"
)
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
// Technically, all alert providers should be reachable using the custom alert provider
type AlertProvider struct {
Url string `yaml:"url"`
Method string `yaml:"method,omitempty"`
@@ -17,10 +19,12 @@ type AlertProvider struct {
Headers map[string]string `yaml:"headers,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.Url) > 0
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *AlertProvider {
return provider
}

View File

@@ -6,10 +6,12 @@ import (
"github.com/TwinProduction/gatus/core"
)
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.IntegrationKey) == 32
}

View File

@@ -5,7 +5,11 @@ import (
"github.com/TwinProduction/gatus/core"
)
// AlertProvider is the interface that each providers should implement
type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid
IsValid() bool
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider
}

View File

@@ -6,14 +6,17 @@ import (
"github.com/TwinProduction/gatus/core"
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
WebhookUrl string `yaml:"webhook-url"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.WebhookUrl) > 0
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var color string

View File

@@ -8,6 +8,7 @@ import (
"net/url"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
type AlertProvider struct {
SID string `yaml:"sid"`
Token string `yaml:"token"`
@@ -15,10 +16,12 @@ type AlertProvider struct {
To string `yaml:"to"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
if resolved {

View File

@@ -12,6 +12,8 @@ import (
)
const (
// DefaultConfigurationFilePath is the default path that will be used to search for the configuration file
// if a custom path isn't configured through the GATUS_CONFIG_FILE environment variable
DefaultConfigurationFilePath = "config/config.yaml"
)
@@ -22,6 +24,7 @@ var (
config *Config
)
// Config is the main configuration structure
type Config struct {
Metrics bool `yaml:"metrics"`
Debug bool `yaml:"debug"`

View File

@@ -2,23 +2,25 @@ package core
import (
"fmt"
"github.com/TwinProduction/gatus/pattern"
"log"
"strings"
)
type Condition string
// evaluate the Condition with the Result of the health check
func (c *Condition) evaluate(result *Result) bool {
condition := string(*c)
success := false
var resolvedCondition string
if strings.Contains(condition, "==") {
parts := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = parts[0] == parts[1]
success = isEqual(parts[0], parts[1])
resolvedCondition = fmt.Sprintf("%v == %v", parts[0], parts[1])
} else if strings.Contains(condition, "!=") {
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = parts[0] != parts[1]
success = !isEqual(parts[0], parts[1])
resolvedCondition = fmt.Sprintf("%v != %v", parts[0], parts[1])
} else if strings.Contains(condition, "<=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
@@ -49,3 +51,26 @@ func (c *Condition) evaluate(result *Result) bool {
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success
}
// isEqual compares two strings.
//
// It also supports the pattern function. That is to say, if one of the strings starts with PatternFunctionPrefix
// and ends with FunctionSuffix, it will be treated like a pattern.
func isEqual(first, second string) bool {
var isFirstPattern, isSecondPattern bool
if strings.HasPrefix(first, PatternFunctionPrefix) && strings.HasSuffix(first, FunctionSuffix) {
isFirstPattern = true
first = strings.TrimSuffix(strings.TrimPrefix(first, PatternFunctionPrefix), FunctionSuffix)
}
if strings.HasPrefix(second, PatternFunctionPrefix) && strings.HasSuffix(second, FunctionSuffix) {
isSecondPattern = true
second = strings.TrimSuffix(strings.TrimPrefix(second, PatternFunctionPrefix), FunctionSuffix)
}
if isFirstPattern && !isSecondPattern {
return pattern.Match(first, second)
} else if !isFirstPattern && isSecondPattern {
return pattern.Match(second, first)
} else {
return first == second
}
}

View File

@@ -113,6 +113,24 @@ func TestCondition_evaluateWithBodyJsonPathComplex(t *testing.T) {
}
}
func TestCondition_evaluateWithBodyJsonPathDoublePlaceholders(t *testing.T) {
condition := Condition("[BODY].user.firstName != [BODY].user.lastName")
result := &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithBodyJsonPathDoublePlaceholdersFailure(t *testing.T) {
condition := Condition("[BODY].user.firstName == [BODY].user.lastName")
result := &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestCondition_evaluateWithBodyJsonPathLongInt(t *testing.T) {
condition := Condition("[BODY].data.id == 1")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
@@ -184,3 +202,66 @@ func TestCondition_evaluateWithBodyStringLength(t *testing.T) {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithBodyStringPattern(t *testing.T) {
condition := Condition("[BODY].name == pat(*ohn*)")
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithBodyStringPatternFailure(t *testing.T) {
condition := Condition("[BODY].name == pat(bob*)")
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestCondition_evaluateWithBodyPatternFailure(t *testing.T) {
condition := Condition("[BODY] == pat(*john*)")
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithIPPattern(t *testing.T) {
condition := Condition("[IP] == pat(10.*)")
result := &Result{Ip: "10.0.0.0"}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithIPPatternFailure(t *testing.T) {
condition := Condition("[IP] == pat(10.*)")
result := &Result{Ip: "255.255.255.255"}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestCondition_evaluateWithStatusPattern(t *testing.T) {
condition := Condition("[STATUS] == pat(4*)")
result := &Result{HttpStatus: 404}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithStatusPatternFailure(t *testing.T) {
condition := Condition("[STATUS] != pat(4*)")
result := &Result{HttpStatus: 404}
condition.evaluate(result)
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}

View File

@@ -13,12 +13,14 @@ const (
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]"
LengthFunctionPrefix = "len("
FunctionSuffix = ")"
LengthFunctionPrefix = "len("
PatternFunctionPrefix = "pat("
FunctionSuffix = ")"
InvalidConditionElementSuffix = "(INVALID)"
)
// sanitizeAndResolve sanitizes and resolves a list of element and returns the list of resolved elements
func sanitizeAndResolve(list []string, result *Result) []string {
var sanitizedList []string
body := strings.TrimSpace(string(result.Body))
@@ -34,7 +36,7 @@ func sanitizeAndResolve(list []string, result *Result) []string {
case BodyPlaceHolder:
element = body
default:
// if starts with BodyPlaceHolder, then evaluate json path
// if contains the BodyPlaceHolder, then evaluate json path
if strings.Contains(element, BodyPlaceHolder) {
wantLength := false
if strings.HasPrefix(element, LengthFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {

12
pattern/pattern.go Normal file
View File

@@ -0,0 +1,12 @@
package pattern
import "path/filepath"
// Match checks whether a string matches a pattern
func Match(pattern, s string) bool {
if pattern == "*" {
return true
}
matched, _ := filepath.Match(pattern, s)
return matched
}

37
pattern/pattern_test.go Normal file
View File

@@ -0,0 +1,37 @@
package pattern
import "testing"
func TestMatch(t *testing.T) {
testMatch(t, "*", "livingroom_123", true)
testMatch(t, "**", "livingroom_123", true)
testMatch(t, "living*", "livingroom_123", true)
testMatch(t, "*living*", "livingroom_123", true)
testMatch(t, "*123", "livingroom_123", true)
testMatch(t, "*_*", "livingroom_123", true)
testMatch(t, "living*_*3", "livingroom_123", true)
testMatch(t, "living*room_*3", "livingroom_123", true)
testMatch(t, "living*room_*3", "livingroom_123", true)
testMatch(t, "*vin*om*2*", "livingroom_123", true)
testMatch(t, "livingroom_123", "livingroom_123", true)
testMatch(t, "*livingroom_123*", "livingroom_123", true)
testMatch(t, "livingroom", "livingroom_123", false)
testMatch(t, "livingroom123", "livingroom_123", false)
testMatch(t, "what", "livingroom_123", false)
testMatch(t, "*what*", "livingroom_123", false)
testMatch(t, "*.*", "livingroom_123", false)
testMatch(t, "room*123", "livingroom_123", false)
}
func testMatch(t *testing.T, pattern, key string, expectedToMatch bool) {
matched := Match(pattern, key)
if expectedToMatch {
if !matched {
t.Errorf("%s should've matched pattern '%s'", key, pattern)
}
} else {
if matched {
t.Errorf("%s shouldn't have matched pattern '%s'", key, pattern)
}
}
}

View File

@@ -30,13 +30,13 @@ func handleAlertsToTrigger(service *core.Service, result *core.Result, cfg *conf
}
if alert.Triggered {
if cfg.Debug {
log.Printf("[watchdog][handleAlertsToTrigger] Alert with description='%s' has already been triggered, skipping", alert.Description)
log.Printf("[watchdog][handleAlertsToTrigger] Alert with description='%s' has already been TRIGGERED, skipping", alert.Description)
}
continue
}
alertProvider := config.GetAlertingProviderByAlertType(cfg, alert.Type)
if alertProvider != nil && alertProvider.IsValid() {
log.Printf("[watchdog][handleAlertsToTrigger] Sending %s alert because alert with description='%s' has been triggered", alert.Type, alert.Description)
log.Printf("[watchdog][handleAlertsToTrigger] Sending %s alert because alert with description='%s' has been TRIGGERED", alert.Type, alert.Description)
customAlertProvider := alertProvider.ToCustomAlertProvider(service, alert, result, false)
// TODO: retry on error
var err error
@@ -64,7 +64,7 @@ func handleAlertsToTrigger(service *core.Service, result *core.Result, cfg *conf
}
} else {
log.Printf("[watchdog][handleAlertsToResolve] Not sending alert of type=%s despite being triggered, because the provider wasn't configured properly", alert.Type)
log.Printf("[watchdog][handleAlertsToResolve] Not sending alert of type=%s despite being TRIGGERED, because the provider wasn't configured properly", alert.Type)
}
}
}
@@ -81,7 +81,7 @@ func handleAlertsToResolve(service *core.Service, result *core.Result, cfg *conf
}
alertProvider := config.GetAlertingProviderByAlertType(cfg, alert.Type)
if alertProvider != nil && alertProvider.IsValid() {
log.Printf("[watchdog][handleAlertsToResolve] Sending %s alert because alert with description='%s' has been resolved", alert.Type, alert.Description)
log.Printf("[watchdog][handleAlertsToResolve] Sending %s alert because alert with description='%s' has been RESOLVED", alert.Type, alert.Description)
customAlertProvider := alertProvider.ToCustomAlertProvider(service, alert, result, true)
// TODO: retry on error
_, err := customAlertProvider.Send(service.Name, alert.Description, true)
@@ -93,7 +93,7 @@ func handleAlertsToResolve(service *core.Service, result *core.Result, cfg *conf
}
}
} else {
log.Printf("[watchdog][handleAlertsToResolve] Not sending alert of type=%s despite being resolved, because the provider wasn't configured properly", alert.Type)
log.Printf("[watchdog][handleAlertsToResolve] Not sending alert of type=%s despite being RESOLVED, because the provider wasn't configured properly", alert.Type)
}
}
service.NumberOfFailuresInARow = 0