Compare commits

...

45 Commits

Author SHA1 Message Date
TwinProduction
c23ba7b85d Update documentation 2020-12-18 18:40:20 -05:00
TwinProduction
0541adec5e Add TestService_buildHTTPRequestWithGraphQLEnabled test 2020-12-18 18:40:11 -05:00
TwinProduction
eee5bc8f9d Automatically add Content-Type: application/json header for GraphQL requests, unless a different Content-Type is already defined 2020-12-18 18:39:22 -05:00
TwinProduction
4d186c6e71 Improve test coverage 2020-12-18 18:06:57 -05:00
TwinProduction
4be2273662 Minor update 2020-12-18 18:06:50 -05:00
TwinProduction
ef9ba10e45 Fix #60: Fix undesired behavior when setting Host header 2020-12-18 17:37:03 -05:00
TwinProduction
b35747bf31 Improve documentation 2020-12-13 17:24:16 -05:00
TwinProduction
8cd82605dd Improve documentation 2020-12-12 23:32:26 -05:00
TwinProduction
7f0b862822 Refactor imports 2020-12-10 20:00:42 -05:00
TwinProduction
7f9fe9acdf Rename buildRequest to buildHTTPRequest 2020-12-08 20:03:59 -05:00
TwinProduction
46d476128d Add missing newline 2020-12-05 18:21:35 -05:00
TwinProduction
e309ffb1b6 Don't push latest 2020-12-04 11:26:51 -05:00
TwinProduction
48a1e435e4 Remove linux/arm/v6 2020-12-04 11:16:53 -05:00
Ivan Larin
6c91bbcc4a Crossbuild docker image for different architectures (#55)
* Crossbuild docker image

* Fix yaml syntax

* Github Actions timeout disabled

* Add linux/386, linux/arm/v6, linux/arm/v7, linux/ppc64le, linux/s390x docker images

* Remove linux/386, linux/ppc64le, linux/s390x docker images

* Remove "Github Packages" from build step name

Co-authored-by: Chris C. <twin@twinnation.org>

* Split workflow file to test/docker

* Forward RELEASE variable to GITHUB_ENV

* Bring back timeout for tests

Co-authored-by: Chris C. <twin@twinnation.org>
2020-12-04 10:58:27 -05:00
TwinProduction
d1fe8d93ee Update documentation 2020-12-01 22:25:16 -05:00
TwinProduction
afdbb1ea77 Don't return unnecessary data 2020-11-30 09:40:57 -05:00
TwinProduction
d58a256628 Minor fix 2020-11-30 09:25:14 -05:00
TwinProduction
971967ae78 Don't append / at the end of the path 2020-11-30 09:23:03 -05:00
TwinProduction
43504913b4 Allow duplicate service names as long as they're in a different group (#13) 2020-11-30 08:44:58 -05:00
TwinProduction
f034bd15de Add missing comments 2020-11-29 19:03:40 -05:00
Chris C
35c232d925 Merge pull request #52 from TwinProduction/service-groups
Add service groups
2020-11-27 08:48:54 -05:00
TwinProduction
9c8bfcd19f Add service-status_test.go 2020-11-26 23:45:17 -05:00
TwinProduction
6a08c816e5 Update default configuration 2020-11-26 23:23:59 -05:00
TwinProduction
da4fb10bfc Add documentation for groups 2020-11-26 23:23:51 -05:00
TwinProduction
35b9758b23 Add non-grouped services at the bottom of the dashboard 2020-11-26 18:29:11 -05:00
TwinProduction
94eb3868e6 Start working on #13: Service groups 2020-11-26 18:09:01 -05:00
TwinProduction
68f32b0fcc Minor update 2020-11-24 19:03:48 -05:00
Chris C
66946e20f0 Merge pull request #51 from olimpias/add-messagebird-alerting-provider
Add Messagebird alerting
2020-11-24 18:58:28 -05:00
cemturker
64f1c41e0c Fix the test 2020-11-25 00:31:51 +01:00
cemturker
199f14667e put property name back as originator and recipients 2020-11-25 00:30:23 +01:00
cemturker
c5e2b0f159 Fix wrong set property names in readme 2020-11-24 21:05:27 +01:00
cemturker
a645c14452 fix the test error outputs 2020-11-24 17:16:54 +01:00
cemturker
daf27fd6d0 Review changes 2020-11-24 17:15:52 +01:00
cemturker
978acf3cd2 Fix the field name in test 2020-11-23 22:29:57 +01:00
cemturker
a870d3e43f Add Messagebird as an alerting provider 2020-11-23 22:20:06 +01:00
TwinProduction
66bf1c23df Minor updates 2020-11-21 17:53:26 -05:00
TwinProduction
4f17bc1b14 Improve test coverage 2020-11-21 17:53:18 -05:00
TwinProduction
5850ed82e4 Refactor code and update comments 2020-11-21 17:35:08 -05:00
Chris C
53e1012ca1 Merge pull request #46 from mindcrime-ilab/feature/context-root
Feature/context root
2020-11-21 17:09:34 -05:00
Michael Engelhardt
f36d99ef07 add test for nested context 2020-11-21 22:41:55 +01:00
Michael Engelhardt
9af8a02f05 Rename function to PrependWithContextRoot 2020-11-21 21:38:45 +01:00
Michael Engelhardt
c827eb7948 some refactorings and add test cases 2020-11-21 13:11:37 +01:00
Michael Engelhardt
c791986143 add test 2020-11-21 04:07:38 +01:00
Michael Engelhardt
5f10a92c36 simplification and refactorings 2020-11-21 03:42:42 +01:00
Michael Engelhardt
f9706a98ed start working on context root configuration 2020-11-21 02:33:02 +01:00
34 changed files with 807 additions and 113 deletions

BIN
.github/assets/service-groups.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

32
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: publish
on:
release:
types: [published]
jobs:
build:
name: Publish
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}

106
README.md
View File

@@ -27,6 +27,7 @@ core applications: https://status.twinnation.org/
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Kubernetes (ALPHA)](#kubernetes-alpha)
- [Auto Discovery](#auto-discovery)
@@ -42,6 +43,8 @@ core applications: https://status.twinnation.org/
- [Monitoring using DNS queries](#monitoring-using-dns-queries)
- [Basic authentication](#basic-authentication)
- [disable-monitoring-lock](#disable-monitoring-lock)
- [Service groups](#service-groups)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
## Features
@@ -50,7 +53,7 @@ The main features of Gatus are:
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Metrics**
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
- **Service auto discovery in Kubernetes** (ALPHA)
@@ -96,6 +99,7 @@ Note that you can also add environment variables in the configuration file (i.e.
| `metrics` | Whether to expose metrics at /metrics | `false` |
| `services` | List of services to monitor | Required `[]` |
| `services[].name` | Name of the service. Can be anything. | Required `""` |
| `services[].group` | Group name. Used to group multiple services together on the dashboard. See [Service groups](#service-groups). | `""` |
| `services[].url` | URL to send the request to | Required `""` |
| `services[].method` | Request method | `GET` |
| `services[].insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
@@ -107,7 +111,7 @@ Note that you can also add environment variables in the configuration file (i.e.
| `services[].dns` | Configuration for a service of type DNS. See [Monitoring using DNS queries](#monitoring-using-dns-queries) | `""` |
| `services[].dns.query-type` | Query type for DNS service | `""` |
| `services[].dns.query-name` | Query name for DNS service | `""` |
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `pagerduty`, `twilio`, `mattermost`, `custom` | Required `""` |
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `custom` | Required `""` |
| `services[].alerts[].enabled` | Whether to enable the alert | `false` |
| `services[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert | `3` |
| `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved | `2` |
@@ -126,6 +130,10 @@ Note that you can also add environment variables in the configuration file (i.e.
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
| `alerting.mattermost.insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
| `alerting.custom.url` | Custom alerting request url | Required `""` |
| `alerting.custom.method` | Request method | `GET` |
@@ -137,8 +145,10 @@ Note that you can also add environment variables in the configuration file (i.e.
| `security.basic.username` | Username for Basic authentication | Required `""` |
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication | Required `""` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock) | `false` |
| `web` | Web configuration | `{}` |
| `web.address` | Address to listen on | `0.0.0.0` |
| `web.port` | Port to listen on | `8080` |
| `web.context-root` | Context root at which Gatus will be exposed (frontend and backend) | `/` |
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha)
@@ -305,6 +315,33 @@ Here's an example of what the notifications look like:
![Mattermost notifications](.github/assets/mattermost-alerts.png)
#### Configuring Messagebird alerts
Example of sending **SMS** text message alert using Messagebird:
```yaml
alerting:
messagebird:
access-key: "..."
originator: "31619191918"
recipients: "31619191919,31619191920"
services:
- name: twinnation
interval: 30s
url: "https://twinnation.org/health"
alerts:
- type: messagebird
enabled: true
failure-threshold: 3
send-on-resolved: true
description: "healthcheck failed 3 times in a row"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
```
#### Configuring custom alerts
While they're called alerts, you can use this feature to call anything.
@@ -427,6 +464,7 @@ If you're on Windows, replace `"$(pwd)"` by the absolute path to your current di
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
```
@@ -462,7 +500,7 @@ services:
}
}
headers:
Content-Type: application/json
Content-Type: application/json # XXX: as of v1.9.2, this header is automatically added when graphql is set to true
conditions:
- "[STATUS] == 200"
- "[BODY].data.user[0].gender == female"
@@ -581,3 +619,65 @@ There are three main reasons why you might want to disable the monitoring lock:
technically, if you create 100 services with a 1 seconds interval, Gatus will send 100 requests per second)
- You have a _lot_ of services to monitor
- You want to test multiple services at very short interval (< 5s)
### Service groups
Service groups are used for grouping multiple services together on the dashboard.
```yaml
services:
- name: frontend
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: backend
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: monitoring
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: random service that isn't part of a group
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
```
The configuration above will result in a dashboard that looks like this:
![Gatus Service Groups](.github/assets/service-groups.png)
### Exposing Gatus on a custom port
By default, Gatus is exposed on port `8080`, but you may specify a different port by setting the `web.port` parameter:
```yaml
web:
port: 8081
```
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
variable instead, you can use that environment variable directly in the configuration file:
```yaml
web:
port: ${PORT}
```

View File

@@ -2,9 +2,10 @@ package alerting
import (
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/alerting/provider/slack"
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
)
@@ -16,6 +17,9 @@ type Config struct {
// Mattermost is the configuration for the mattermost alerting provider
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
// Pagerduty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`

View File

@@ -31,7 +31,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
return provider
}
func (provider *AlertProvider) buildRequest(serviceName, alertDescription string, resolved bool) *http.Request {
func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription string, resolved bool) *http.Request {
body := provider.Body
providerURL := provider.URL
method := provider.Method
@@ -62,7 +62,7 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
}
}
if len(method) == 0 {
method = "GET"
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, providerURL, bodyBuffer)
@@ -74,7 +74,7 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
// Send a request to the alert provider and return the body
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
request := provider.buildRequest(serviceName, alertDescription, resolved)
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
if err != nil {
return nil, err

View File

@@ -1,9 +1,10 @@
package custom
import (
"github.com/TwinProduction/gatus/core"
"io/ioutil"
"testing"
"github.com/TwinProduction/gatus/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -17,7 +18,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
const (
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
ExpectedBody = "service-name,alert-description,RESOLVED"
@@ -27,7 +28,7 @@ func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
}
request := customAlertProvider.buildRequest("service-name", "alert-description", true)
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
@@ -37,7 +38,7 @@ func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
}
}
func TestAlertProvider_buildRequestWhenTriggered(t *testing.T) {
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
const (
ExpectedURL = "http://example.com/service-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "service-name,alert-description,TRIGGERED"
@@ -47,7 +48,7 @@ func TestAlertProvider_buildRequestWhenTriggered(t *testing.T) {
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: map[string]string{"Authorization": "Basic hunter2"},
}
request := customAlertProvider.buildRequest("service-name", "alert-description", false)
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}

View File

@@ -2,6 +2,8 @@ package mattermost
import (
"fmt"
"net/http"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
)
@@ -40,7 +42,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: "POST",
Method: http.MethodPost,
Insecure: provider.Insecure,
Body: fmt.Sprintf(`{
"text": "",

View File

@@ -1,9 +1,10 @@
package mattermost
import (
"github.com/TwinProduction/gatus/core"
"strings"
"testing"
"github.com/TwinProduction/gatus/core"
)
func TestAlertProvider_IsValid(t *testing.T) {

View File

@@ -0,0 +1,50 @@
package messagebird
import (
"fmt"
"net/http"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
)
const (
restAPIURL = "https://rest.messagebird.com/messages"
)
// AlertProvider is the configuration necessary for sending an alert using Messagebird
type AlertProvider struct {
AccessKey string `yaml:"access-key"`
Originator string `yaml:"originator"`
Recipients string `yaml:"recipients"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
}
return &custom.AlertProvider{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"originator": "%s",
"recipients": "%s",
"body": "%s"
}`, provider.Originator, provider.Recipients, message),
Headers: map[string]string{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
},
}
}

View File

@@ -0,0 +1,53 @@
package messagebird
import (
"strings"
"testing"
"github.com/TwinProduction/gatus/core"
)
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Error("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
if customAlertProvider == nil {
t.Error("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
}
}

View File

@@ -2,6 +2,8 @@ package pagerduty
import (
"fmt"
"net/http"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
)
@@ -19,7 +21,7 @@ func (provider *AlertProvider) IsValid() bool {
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
//
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
var message, eventAction, resolveKey string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
@@ -32,7 +34,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
}
return &custom.AlertProvider{
URL: "https://events.pagerduty.com/v2/enqueue",
Method: "POST",
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"routing_key": "%s",
"dedup_key": "%s",

View File

@@ -1,9 +1,10 @@
package pagerduty
import (
"github.com/TwinProduction/gatus/core"
"strings"
"testing"
"github.com/TwinProduction/gatus/core"
)
func TestAlertProvider_IsValid(t *testing.T) {

View File

@@ -2,9 +2,10 @@ package provider
import (
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/alerting/provider/slack"
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
"github.com/TwinProduction/gatus/core"
)
@@ -24,5 +25,6 @@ var (
_ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
)

View File

@@ -2,6 +2,7 @@ package slack
import (
"fmt"
"net/http"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
@@ -38,7 +39,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: "POST",
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"text": "",
"attachments": [

View File

@@ -3,9 +3,11 @@ package twilio
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"net/url"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -22,7 +24,7 @@ func (provider *AlertProvider) IsValid() bool {
}
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
@@ -31,7 +33,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
}
return &custom.AlertProvider{
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
Method: "POST",
Method: http.MethodPost,
Body: url.Values{
"To": {provider.To},
"From": {provider.From},

View File

@@ -1,9 +1,10 @@
package twilio
import (
"github.com/TwinProduction/gatus/core"
"strings"
"testing"
"github.com/TwinProduction/gatus/core"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {

View File

@@ -1,15 +1,37 @@
metrics: true
services:
- name: twinnation
- name: frontend
group: core
url: "https://twinnation.org/health"
interval: 30s
interval: 1m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 1000"
- name: backend
group: core
url: "http://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: monitoring
group: internal
url: "http://example.com/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: cat-fact
url: "https://cat-fact.herokuapp.com/facts/random"
interval: 1m
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].deleted == false"

View File

@@ -28,6 +28,9 @@ const (
// DefaultPort is the default port the service will listen on
DefaultPort = 8080
// DefaultContextRoot is the default context root of the web application
DefaultContextRoot = "/"
)
var (
@@ -84,6 +87,7 @@ func Get() *Config {
}
// Load loads a custom configuration file
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
func Load(configFile string) error {
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
cfg, err := readConfigurationFile(configFile)
@@ -143,7 +147,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
func validateWebConfig(config *Config) {
if config.Web == nil {
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort}
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot}
} else {
config.Web.validateAndSetDefaults()
}
@@ -198,6 +202,7 @@ func validateAlertingConfig(config *Config) {
alertTypes := []core.AlertType{
core.SlackAlert,
core.MattermostAlert,
core.MessagebirdAlert,
core.TwilioAlert,
core.PagerDutyAlert,
core.CustomAlert,
@@ -234,6 +239,12 @@ func GetAlertingProviderByAlertType(config *Config, alertType core.AlertType) pr
return nil
}
return config.Alerting.Mattermost
case core.MessagebirdAlert:
if config.Alerting.Messagebird == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Alerting.Messagebird
case core.TwilioAlert:
if config.Alerting.Twilio == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil

View File

@@ -109,6 +109,9 @@ services:
if config.Web.Port != DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
}
if config.Web.ContextRoot != DefaultContextRoot {
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
}
}
func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
@@ -215,6 +218,44 @@ services:
}
}
func TestParseAndValidateConfigBytesWithPortAndHostAndContextRoot(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
web:
port: 12345
address: 127.0.0.1
context-root: /deeply/nested/down=/their
services:
- name: twinnation
url: https://twinnation.org/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("No error should've been returned")
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if config.Web.Address != "127.0.0.1" {
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
}
if config.Web.Port != 12345 {
t.Errorf("Port should have been %d, because it is specified in config", 12345)
}
if config.Web.ContextRoot != "/deeply/nested/down=/their/" {
t.Errorf("Port should have been %s, because it is specified in config", "/deeply/nested/down=/their/")
}
}
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
defer func() { recover() }()
_, _ = parseAndValidateConfigBytes([]byte(`
@@ -260,6 +301,9 @@ services:
if config.Web.Port != DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
}
if config.Web.ContextRoot != DefaultContextRoot {
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
}
}
func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) {
@@ -322,6 +366,10 @@ alerting:
webhook-url: "http://example.com"
pagerduty:
integration-key: "00000000000000000000000000000000"
messagebird:
access-key: "1"
originator: "31619191918"
recipients: "31619191919"
services:
- name: twinnation
url: https://twinnation.org/health
@@ -333,6 +381,8 @@ services:
failure-threshold: 7
success-threshold: 5
description: "Healthcheck failed 7 times in a row"
- type: messagebird
enabled: true
conditions:
- "[STATUS] == 200"
`))
@@ -357,9 +407,21 @@ services:
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
t.Fatal("PagerDuty alerting config should've been valid")
}
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() {
t.Fatal("Messagebird alerting config should've been valid")
}
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
}
if config.Alerting.Messagebird.AccessKey != "1" {
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey)
}
if config.Alerting.Messagebird.Originator != "31619191918" {
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.Originator)
}
if config.Alerting.Messagebird.Recipients != "31619191919" {
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients)
}
if len(config.Services) != 1 {
t.Error("There should've been 1 service")
}
@@ -372,8 +434,8 @@ services:
if config.Services[0].Alerts == nil {
t.Fatal("The service alerts shouldn't have been nil")
}
if len(config.Services[0].Alerts) != 2 {
t.Fatal("There should've been 2 alert configured")
if len(config.Services[0].Alerts) != 3 {
t.Fatal("There should've been 3 alert configured")
}
if !config.Services[0].Alerts[0].Enabled {
t.Error("The alert should've been enabled")
@@ -399,6 +461,9 @@ services:
if config.Services[0].Alerts[1].Description != "Healthcheck failed 7 times in a row" {
t.Errorf("The description of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Services[0].Alerts[0].Description)
}
if config.Services[0].Alerts[2].Type != core.MessagebirdAlert {
t.Errorf("The type of the alert should've been %s, but it was %s", core.MessagebirdAlert, config.Services[0].Alerts[1].Type)
}
}
func TestParseAndValidateConfigBytesWithInvalidPagerDutyAlertingConfig(t *testing.T) {

View File

@@ -3,6 +3,8 @@ package config
import (
"fmt"
"math"
"net/url"
"strings"
)
// webConfig is the structure which supports the configuration of the endpoint
@@ -13,18 +15,40 @@ type webConfig struct {
// Port to listen on (default to 8080 specified by DefaultPort)
Port int `yaml:"port"`
// ContextRoot set the root context for the web application
ContextRoot string `yaml:"context-root"`
}
// validateAndSetDefaults checks and sets missing values based on the defaults
// in given in DefaultAddress and DefaultPort if necessary
// validateAndSetDefaults checks and sets the default values for fields that are not set
func (web *webConfig) validateAndSetDefaults() {
// Validate the Address
if len(web.Address) == 0 {
web.Address = DefaultAddress
}
// Validate the Port
if web.Port == 0 {
web.Port = DefaultPort
} else if web.Port < 0 || web.Port > math.MaxUint16 {
panic(fmt.Sprintf("port has an invalid: value should be between %d and %d", 0, math.MaxUint16))
panic(fmt.Sprintf("invalid port: value should be between %d and %d", 0, math.MaxUint16))
}
// Validate the ContextRoot
if len(web.ContextRoot) == 0 {
web.ContextRoot = DefaultContextRoot
} else {
trimmedContextRoot := strings.Trim(web.ContextRoot, "/")
if len(trimmedContextRoot) == 0 {
web.ContextRoot = DefaultContextRoot
return
}
rootContextURL, err := url.Parse(trimmedContextRoot)
if err != nil {
panic("invalid context root:" + err.Error())
}
if rootContextURL.Path != trimmedContextRoot {
panic("invalid context root: too complex")
}
web.ContextRoot = "/" + strings.Trim(rootContextURL.Path, "/") + "/"
}
}
@@ -32,3 +56,8 @@ func (web *webConfig) validateAndSetDefaults() {
func (web *webConfig) SocketAddress() string {
return fmt.Sprintf("%s:%d", web.Address, web.Port)
}
// PrependWithContextRoot appends the given path to the ContextRoot
func (web *webConfig) PrependWithContextRoot(path string) string {
return web.ContextRoot + strings.Trim(path, "/")
}

View File

@@ -1,6 +1,9 @@
package config
import "testing"
import (
"fmt"
"testing"
)
func TestWebConfig_SocketAddress(t *testing.T) {
web := &webConfig{
@@ -11,3 +14,86 @@ func TestWebConfig_SocketAddress(t *testing.T) {
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
}
}
func TestWebConfig_PrependWithContextRoot(t *testing.T) {
web := &webConfig{ContextRoot: "/status/"}
if result := web.PrependWithContextRoot("/api/v1/results"); result != "/status/api/v1/results" {
t.Errorf("expected %s, got %s", "/status/api/v1/results", result)
}
if result := web.PrependWithContextRoot("/health"); result != "/status/health" {
t.Errorf("expected %s, got %s", "/status/health", result)
}
if result := web.PrependWithContextRoot("/health/"); result != "/status/health" {
t.Errorf("expected %s, got %s", "/status/health", result)
}
}
// validContextRootTest specifies all test case which should end up in
// a valid context root used to bind the web interface to
var validContextRootTests = []struct {
name string
path string
expectedPath string
}{
{"Empty", "", "/"},
{"/", "/", "/"},
{"///", "///", "/"},
{"Single character 'a'", "a", "/a/"},
{"Slash at the beginning", "/status", "/status/"},
{"Slashes at start and end", "/status/", "/status/"},
{"Multiple slashes at start", "//status", "/status/"},
{"Multiple slashes at start and end", "///status////", "/status/"},
{"Contains '@' in path'", "me@/status/gatus", "/me@/status/gatus/"},
{"Nested context with trailing slash", "/status/gatus/", "/status/gatus/"},
{"Nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"},
}
func TestWebConfig_ValidContextRoots(t *testing.T) {
for idx, test := range validContextRootTests {
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
expectValidResultForContextRoot(t, test.path, test.expectedPath)
})
}
}
func expectValidResultForContextRoot(t *testing.T, path string, expected string) {
web := &webConfig{
ContextRoot: path,
}
web.validateAndSetDefaults()
if web.ContextRoot != expected {
t.Errorf("expected %s, got %s", expected, web.ContextRoot)
}
}
// invalidContextRootTests contains all tests for context root which are
// expected to fail and stop program execution
var invalidContextRootTests = []struct {
name string
path string
}{
{"Only a fragment identifier", "#"},
{"Invalid character in path", "/invalid" + string([]byte{0x7F})},
{"Starts with protocol", "http://status/gatus"},
{"Path with fragment", "/status/gatus#here"},
{"Starts with '://'", "://status"},
{"Contains query parameter", "/status/h?ello=world"},
{"Contains '?'", "/status?"},
}
func TestWebConfig_InvalidContextRoots(t *testing.T) {
for idx, test := range invalidContextRootTests {
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
expectInvalidResultForContextRoot(t, test.path)
})
}
}
func expectInvalidResultForContextRoot(t *testing.T, path string) {
defer func() { recover() }()
web := &webConfig{ContextRoot: path}
web.validateAndSetDefaults()
t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path))
}

View File

@@ -40,6 +40,9 @@ const (
// MattermostAlert is the AlertType for the mattermost alerting provider
MattermostAlert AlertType = "mattermost"
// MessagebirdAlert is the AlertType for the messagebird alerting provider
MessagebirdAlert AlertType = "messagebird"
// PagerDutyAlert is the AlertType for the pagerduty alerting provider
PagerDutyAlert AlertType = "pagerduty"

10
core/condition-result.go Normal file
View File

@@ -0,0 +1,10 @@
package core
// 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"`
}

View File

@@ -11,6 +11,7 @@ import (
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 for DNS")
// ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type
ErrDNSWithInvalidQueryType = errors.New("invalid query type")
)
@@ -19,9 +20,11 @@ const (
dnsPort = 53
)
// DNS is the configuration for a Service of type DNS
type DNS 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"`
}

11
core/health-status.go Normal file
View File

@@ -0,0 +1,11 @@
package core
// HealthStatus is the status of Gatus
type HealthStatus struct {
// Status is the state of Gatus (UP/DOWN)
Status string `json:"status"`
// Message is an accompanying description of why the status is as reported.
// If the Status is UP, no message will be provided
Message string `json:"message,omitempty"`
}

View File

@@ -4,23 +4,13 @@ import (
"time"
)
// HealthStatus is the status of Gatus
type HealthStatus struct {
// Status is the state of Gatus (UP/DOWN)
Status string `json:"status"`
// Message is an accompanying description of why the status is as reported.
// If the Status is UP, no message will be provided
Message string `json:"message,omitempty"`
}
// Result of the evaluation of a Service
type Result struct {
// HTTPStatus is the HTTP response status code
HTTPStatus int `json:"status"`
// DNSRCode is the response code of DNS query in human readable version
DNSRCode string `json:"dns-rcode"`
// DNSRCode is the response code of a DNS query in a human readable format
DNSRCode string `json:"-"`
// Body is the response body
Body []byte `json:"-"`
@@ -50,14 +40,5 @@ type Result struct {
Timestamp time.Time `json:"timestamp"`
// CertificateExpiration is the duration before the certificate expires
CertificateExpiration time.Duration `json:"certificate-expiration,omitempty"`
}
// 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"`
CertificateExpiration time.Duration `json:"-"`
}

31
core/service-status.go Normal file
View File

@@ -0,0 +1,31 @@
package core
// ServiceStatus contains the evaluation Results of a Service
type ServiceStatus struct {
// Name of the service
Name string `json:"name,omitempty"`
// Group the service is a part of. Used for grouping multiple services together on the front end.
Group string `json:"group,omitempty"`
// Results is the list of service evaluation results
Results []*Result `json:"results"`
}
// NewServiceStatus creates a new ServiceStatus
func NewServiceStatus(service *Service) *ServiceStatus {
return &ServiceStatus{
Name: service.Name,
Group: service.Group,
Results: make([]*Result, 0),
}
}
// AddResult adds a Result to ServiceStatus.Results and makes sure that there are
// no more than 20 results in the Results slice
func (ss *ServiceStatus) AddResult(result *Result) {
ss.Results = append(ss.Results, result)
if len(ss.Results) > 20 {
ss.Results = ss.Results[1:]
}
}

View File

@@ -0,0 +1,25 @@
package core
import "testing"
func TestNewServiceStatus(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service)
if serviceStatus.Name != service.Name {
t.Errorf("expected %s, got %s", service.Name, serviceStatus.Name)
}
if serviceStatus.Group != service.Group {
t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group)
}
}
func TestServiceStatus_AddResult(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service)
for i := 0; i < 50; i++ {
serviceStatus.AddResult(&Result{})
}
if len(serviceStatus.Results) != 20 {
t.Errorf("expected serviceStatus.Results to not exceed a length of 20")
}
}

View File

@@ -14,6 +14,14 @@ import (
"github.com/TwinProduction/gatus/client"
)
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"
)
var (
// ErrServiceWithNoCondition is the error with which gatus will panic if a service is configured with no conditions
ErrServiceWithNoCondition = errors.New("you must specify at least one condition per service")
@@ -30,6 +38,9 @@ type Service struct {
// Name of the service. Can be anything.
Name string `yaml:"name"`
// Group the service is a part of. Used for grouping multiple services together on the front end.
Group string `yaml:"group,omitempty"`
// URL to send the request to
URL string `yaml:"url"`
@@ -79,6 +90,11 @@ func (service *Service) ValidateAndSetDefaults() {
if len(service.Headers) == 0 {
service.Headers = make(map[string]string)
}
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
// and service.GraphQL is set to true
if _, contentTypeHeaderExists := service.Headers[ContentTypeHeader]; !contentTypeHeaderExists && service.GraphQL {
service.Headers[ContentTypeHeader] = "application/json"
}
for _, alert := range service.Alerts {
if alert.FailureThreshold <= 0 {
alert.FailureThreshold = 3
@@ -213,6 +229,9 @@ func (service *Service) buildHTTPRequest() *http.Request {
request, _ := http.NewRequest(service.Method, service.URL, bodyBuffer)
for k, v := range service.Headers {
request.Header.Set(k, v)
if k == HostHeader {
request.Host = v
}
}
return request
}

View File

@@ -1,6 +1,8 @@
package core
import (
"io/ioutil"
"strings"
"testing"
"time"
)
@@ -113,6 +115,75 @@ func TestService_GetAlertsTriggered(t *testing.T) {
}
}
func TestService_buildHTTPRequest(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "TwiNNatioN",
URL: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
service.ValidateAndSetDefaults()
request := service.buildHTTPRequest()
if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method)
}
if request.Host != "twinnation.org" {
t.Error("request.Host should've been twinnation.org, but was", request.Host)
}
}
func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "TwiNNatioN",
URL: "https://twinnation.org/health",
Method: "POST",
Conditions: []*Condition{&condition},
Headers: map[string]string{
"Host": "example.com",
},
}
service.ValidateAndSetDefaults()
request := service.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 TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "TwiNNatioN",
URL: "https://twinnation.org/graphql",
Method: "POST",
Conditions: []*Condition{&condition},
GraphQL: true,
Body: `{
user(gender: "female") {
id
name
gender
avatar
}
}`,
}
service.ValidateAndSetDefaults()
request := service.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, _ := ioutil.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")
service := Service{

View File

@@ -20,11 +20,14 @@ type gzipResponseWriter struct {
http.ResponseWriter
}
// WriteHeader sends an HTTP response header with the provided status code.
// It also deletes the Content-Length header, since the GZIP compression may modify the size of the payload
func (w *gzipResponseWriter) WriteHeader(status int) {
w.Header().Del("Content-Length")
w.ResponseWriter.WriteHeader(status)
}
// Write writes len(b) bytes from b to the underlying data stream.
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}

45
main.go
View File

@@ -18,24 +18,26 @@ import (
const cacheTTL = 10 * time.Second
var (
cachedServiceResults []byte
cachedServiceResultsGzipped []byte
cachedServiceResultsTimestamp time.Time
cachedServiceStatuses []byte
cachedServiceStatusesGzipped []byte
cachedServiceStatusesTimestamp time.Time
)
func main() {
cfg := loadConfiguration()
resultsHandler := serviceResultsHandler
statusesHandler := serviceStatusesHandler
if cfg.Security != nil && cfg.Security.IsValid() {
resultsHandler = security.Handler(serviceResultsHandler, cfg.Security)
statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security)
}
http.HandleFunc("/api/v1/results", resultsHandler)
http.HandleFunc("/health", healthHandler)
http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static"))))
http.HandleFunc("/favicon.ico", favIconHandler) // favicon needs to be always served from the root
http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler)
http.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler)
http.Handle(cfg.Web.ContextRoot, GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static")))))
if cfg.Metrics {
http.Handle("/metrics", promhttp.Handler())
http.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler())
}
log.Printf("[main][main] Listening on %s\n", cfg.Web.SocketAddress())
log.Printf("[main][main] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot)
go watchdog.Monitor(cfg)
log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil))
}
@@ -54,29 +56,29 @@ func loadConfiguration() *config.Config {
return config.Get()
}
func serviceResultsHandler(writer http.ResponseWriter, r *http.Request) {
if isExpired := cachedServiceResultsTimestamp.IsZero() || time.Now().Sub(cachedServiceResultsTimestamp) > cacheTTL; isExpired {
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
if isExpired := cachedServiceStatusesTimestamp.IsZero() || time.Now().Sub(cachedServiceStatusesTimestamp) > cacheTTL; isExpired {
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
data, err := watchdog.GetJSONEncodedServiceResults()
data, err := watchdog.GetJSONEncodedServiceStatuses()
if err != nil {
log.Printf("[main][serviceResultsHandler] Unable to marshal object to JSON: %s", err.Error())
log.Printf("[main][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
return
}
gzipWriter.Write(data)
gzipWriter.Close()
cachedServiceResults = data
cachedServiceResultsGzipped = buffer.Bytes()
cachedServiceResultsTimestamp = time.Now()
cachedServiceStatuses = data
cachedServiceStatusesGzipped = buffer.Bytes()
cachedServiceStatusesTimestamp = time.Now()
}
var data []byte
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
writer.Header().Set("Content-Encoding", "gzip")
data = cachedServiceResultsGzipped
data = cachedServiceStatusesGzipped
} else {
data = cachedServiceResults
data = cachedServiceStatuses
}
writer.Header().Add("Content-type", "application/json")
writer.WriteHeader(http.StatusOK)
@@ -88,3 +90,8 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
}
// favIconHandler handles requests for /favicon.ico
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, "./static/favicon.ico")
}

View File

@@ -3,7 +3,7 @@
<head>
<title>Health Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/bootstrap.min.css" />
<link rel="stylesheet" href="./bootstrap.min.css" />
<style>
html, body {
background-color: #f7f9fb;
@@ -99,6 +99,13 @@
#settings select:focus {
box-shadow: none;
}
.service-group {
cursor: pointer;
user-select: none;
}
.service-group h5:hover {
color: #1b1e21 !important;
}
</style>
</head>
<body>
@@ -129,11 +136,11 @@
</div>
</div>
<script src="/jquery.min.js"></script>
<script src="./jquery.min.js"></script>
<div id="social">
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
<img src="/github.png" alt="GitHub" width="32" height="auto" />
<img src="./github.png" alt="GitHub" width="32" height="auto" />
</a>
</div>
@@ -162,7 +169,7 @@
function showTooltip(serviceName, index, element) {
userClickedStatus = false;
clearTimeout(timerHandler);
let serviceResult = serviceStatuses[serviceName][index];
let serviceResult = serviceStatuses[serviceName].results[index];
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
// Populate the condition section
@@ -212,15 +219,15 @@
}
}
function createStatusBadge(serviceName, index, success) {
function createStatusBadge(serviceStatusIndex, index, success) {
if (success) {
return "<span class='status badge badge-success' style='width: 5%' onmouseenter='showTooltip(\""+serviceName+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>&#10003;</span>";
return "<span class='status badge badge-success' style='width: 5%' onmouseenter='showTooltip(\""+serviceStatusIndex+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>&#10003;</span>";
}
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\""+serviceName+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\""+serviceStatusIndex+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
}
function refreshResults() {
$.getJSON("/api/v1/results", function (data) {
function refreshStatuses() {
$.getJSON("./api/v1/statuses", function (data) {
// Update the table only if there's a change
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
serviceStatuses = data;
@@ -230,17 +237,18 @@
}
function buildTable() {
let output = "";
for (let serviceName in serviceStatuses) {
let outputByGroup = {};
for (let serviceStatusIndex in serviceStatuses) {
let serviceStatusOverTime = "";
let hostname = serviceStatuses[serviceName][serviceStatuses[serviceName].length-1].hostname
let serviceStatus = serviceStatuses[serviceStatusIndex];
let hostname = serviceStatus.results[serviceStatus.results.length-1].hostname;
let minResponseTime = null;
let maxResponseTime = null;
let newestTimestamp = null;
let oldestTimestamp = null;
for (let key in serviceStatuses[serviceName]) {
let serviceResult = serviceStatuses[serviceName][key];
serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime;
for (let resultIndex in serviceStatus.results) {
let serviceResult = serviceStatus.results[resultIndex];
serviceStatusOverTime = createStatusBadge(serviceStatusIndex, resultIndex, serviceResult.success) + serviceStatusOverTime;
const responseTime = parseInt(serviceResult.duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
@@ -256,11 +264,11 @@
oldestTimestamp = timestamp;
}
}
output += ""
+ "<div class='container py-3 border-left border-right border-top border-black'>"
let output = ""
+ "<div class='container py-3 border-left border-right border-top border-black rounded-0'>"
+ " <div class='row mb-2'>"
+ " <div class='col-md-10'>"
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " <span class='font-weight-bold'>" + serviceStatus.name + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " </div>"
+ " <div class='col-md-2 text-right'>"
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
@@ -280,10 +288,59 @@
+ " </div>"
+ " </div>"
+ "</div>";
// create an empty entry if this group is new
if (!outputByGroup[serviceStatus.group]) {
outputByGroup[serviceStatus.group] = "";
}
outputByGroup[serviceStatus.group] += output;
}
let output = "";
for (let group in outputByGroup) {
// Services that don't have a group should be skipped and left for last
if (group === 'undefined') {
continue
}
let key = group.replace(/[^a-zA-Z0-9]/g, '');
let existingGroupContentSelector = $("#service-group-" + key + "-content");
let isCurrentlyHidden = existingGroupContentSelector.length && existingGroupContentSelector[0].style.display === 'none';
let groupStatus = "<span class='text-success'>&#10003;</span>";
if (outputByGroup[group].includes("badge badge-danger")) {
groupStatus = "<span class='text-warning'>~</span>";
}
output += ""
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
+ " <div class='container pt-2 border-left border-right border-top border-black border-bottom service-group' id='service-group-" + key + "' data-group='" + key + "' onclick='toggleGroup(this)'>"
+ " <h5 class='text-secondary text-monospace pb-0'>"
+ " " + groupStatus + " " + group
+ " <span class='float-right service-group-arrow' id='service-group-" + key + "-arrow'>" + (isCurrentlyHidden ? "&#9660;" : "&#9650;") + "</span>"
+ " </h5>"
+ " </div>"
+ " <div class='service-group-content' id='service-group-" + key + "-content' style='" + (isCurrentlyHidden ? "display: none;" : "") + "'>"
+ " " + outputByGroup[group]
+ " </div>"
+ "</div>";
}
// Add all services that don't have a group at the end
if (outputByGroup['undefined']) {
output += ""
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
+ " " + outputByGroup['undefined']
+ "</div>"
}
$("#results").html(output);
}
function toggleGroup(element) {
let selector = $("#service-group-" + element.dataset.group + "-content");
selector.toggle("fast", function() {
if (selector.length && selector[0].style.display === 'none') {
$("#service-group-" + element.dataset.group + "-arrow").html("&#9660;");
} else {
$("#service-group-" + element.dataset.group + "-arrow").html("&#9650;");
}
});
}
function prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
@@ -318,15 +375,15 @@
}
function setRefreshInterval(seconds) {
refreshResults();
refreshStatuses();
refreshIntervalHandler = setInterval(function() {
refreshResults();
}, seconds * 1000)
refreshStatuses();
}, seconds * 1000);
}
$("#refresh-rate").change(function() {
clearInterval(refreshIntervalHandler);
setRefreshInterval($(this).val())
setRefreshInterval($(this).val());
});
setRefreshInterval(30);
$("#refresh-rate").val(30);

View File

@@ -13,22 +13,22 @@ import (
)
var (
serviceResults = make(map[string][]*core.Result)
serviceStatuses = make(map[string]*core.ServiceStatus)
// serviceResultsMutex is used to prevent concurrent map access
serviceResultsMutex sync.RWMutex
// serviceStatusesMutex is used to prevent concurrent map access
serviceStatusesMutex sync.RWMutex
// monitoringMutex is used to prevent multiple services from being evaluated at the same time.
// Without this, conditions using response time may become inaccurate.
monitoringMutex sync.Mutex
)
// GetJSONEncodedServiceResults returns a list of the last 20 results for each services encoded using json.Marshal.
// GetJSONEncodedServiceStatuses returns a list of core.ServiceStatus for each services encoded using json.Marshal.
// The reason why the encoding is done here is because we use a mutex to prevent concurrent map access.
func GetJSONEncodedServiceResults() ([]byte, error) {
serviceResultsMutex.RLock()
data, err := json.Marshal(serviceResults)
serviceResultsMutex.RUnlock()
func GetJSONEncodedServiceStatuses() ([]byte, error) {
serviceStatusesMutex.RLock()
data, err := json.Marshal(serviceStatuses)
serviceStatusesMutex.RUnlock()
return data, err
}
@@ -55,12 +55,7 @@ func monitor(service *core.Service) {
}
result := service.EvaluateHealth()
metric.PublishMetricsForService(service, result)
serviceResultsMutex.Lock()
serviceResults[service.Name] = append(serviceResults[service.Name], result)
if len(serviceResults[service.Name]) > 20 {
serviceResults[service.Name] = serviceResults[service.Name][1:]
}
serviceResultsMutex.Unlock()
UpdateServiceStatuses(service, result)
var extra string
if !result.Success {
extra = fmt.Sprintf("responseBody=%s", result.Body)
@@ -83,3 +78,16 @@ func monitor(service *core.Service) {
time.Sleep(service.Interval)
}
}
// UpdateServiceStatuses updates the slice of service statuses
func UpdateServiceStatuses(service *core.Service, result *core.Result) {
key := fmt.Sprintf("%s_%s", service.Group, service.Name)
serviceStatusesMutex.Lock()
serviceStatus, exists := serviceStatuses[key]
if !exists {
serviceStatus = core.NewServiceStatus(service)
serviceStatuses[key] = serviceStatus
}
serviceStatus.AddResult(result)
serviceStatusesMutex.Unlock()
}