Compare commits

..

93 Commits

Author SHA1 Message Date
TwiN
5eb7763052 Implement email alerts 2021-12-02 23:31:14 -05:00
TwiN
8c73ae6035 Fix #22: Improve alerting provider tests by mocking HTTP client 2021-12-02 23:10:21 -05:00
TwiN
6954e9dde7 Move alerting provider mocked tests at the watchdog level 2021-12-02 22:15:51 -05:00
TwiN
f6336eac4e Fix #117: Implement email alerts 2021-12-02 21:05:17 -05:00
TwiN
0331c18401 Make sure cache is disabled for badges and charts 2021-11-24 18:36:31 -05:00
TwiN
1f8fd29dad Get rid of Discord link
I don't have time to manage a community 😩
2021-11-23 19:18:29 -05:00
TwiN
5877c4b2be Update TwiN/health to v1.3.0 2021-11-19 23:43:24 -05:00
TwiN
c6d0809ecc Hide Alert.ResolveKey and Alert.Triggered 2021-11-18 00:11:32 -05:00
TwiN
cf8a601104 Add more images to README.md 2021-11-17 21:47:38 -05:00
TwiN
96a0eebc0c Change license to Apache 2 (#203)
Call me indecisive if you want, since I've done this once before (see 70c9c4b87c), but after thing about the pros and cons, I decided that Apache 2 offers better protection for Gatus
2021-11-17 21:47:06 -05:00
TwiN
2af3425b9e Fix #202: Postgres error when an endpoint has a second page but others do not 2021-11-16 22:56:16 -05:00
TwiN
31bf2aeb80 Update TwiN/health to v1.1.0 2021-11-15 20:11:13 -05:00
TwiN
787f6f0d74 Add feedback email address 2021-11-12 00:32:11 -05:00
TwiN
17a431321c Pass http.NoBody instead of nil as body 2021-11-11 00:14:00 -05:00
TwiN
05e9add16d Regenerate static assets 2021-11-09 00:16:48 -05:00
TwiN
c4ef56511d Update dependencies 2021-11-09 00:07:44 -05:00
TwiN
cfa2c8ef6f Minor updates 2021-11-09 00:06:41 -05:00
TwiN
f36b6863ce Minor update 2021-11-08 23:54:06 -05:00
TwiN
24482cf7a0 Fix icon_url for Mattermost 2021-11-08 21:07:16 -05:00
TwiN
d661a0ea6d Add logo.png in .github/assets 2021-11-08 21:05:16 -05:00
TwiN
a0ec6941ab Display number of days rather than hours if >72h 2021-11-08 20:57:58 -05:00
TwiN
5e711fb3b9 Use http.Error instead of writer.Write 2021-11-08 20:56:35 -05:00
TwiN
ab66e7ec8a Fix badge examples 2021-11-08 02:22:43 -05:00
TwiN
08aba6cd51 Minor updates 2021-11-04 21:40:05 -04:00
TwiN
d3805cd77a Fix #197; Fix #198: Deprecate storage.file in favor of storage.path and deprecate persistence with memory storage type 2021-11-04 21:33:13 -04:00
TwiN
dd70136e6c Omit empty hostname and errors field 2021-11-03 22:18:23 -04:00
TwiN
a94c480c22 Fix typo in comment 2021-11-03 22:17:58 -04:00
TwiN
10fd4ecd6b Minor fixes 2021-11-03 19:48:58 -04:00
TwiN
9287e2f9e2 Move store initialization to store package
This will allow importing storage.Config without importing every SQL drivers in the known universe
2021-10-28 19:35:46 -04:00
TwiN
257f859825 Rename getPagerDutyIntegrationKeyForGroup to getIntegrationKeyForGroup 2021-10-27 23:16:05 -04:00
TwiN
3a4ab62ddd #191: Handle memory issue caused by migration from Service to Endpoint 2021-10-24 21:20:01 -04:00
TwiN
a4e9d8e9b0 Revert "Add GATUS_DONT_EXPAND_ENV env var" 2021-10-24 18:34:39 -04:00
TwiN
3be6d04d29 Add GATUS_DONT_EXPAND_ENV env var 2021-10-24 16:20:24 -04:00
TwiN
b59ff6f89e Add ServiceAccount to Kubernetes example 2021-10-24 15:33:15 -04:00
TwiN
813fea93ee #167: Rename examples/minimal to .examples/docker-minimal 2021-10-24 15:27:25 -04:00
TwiN
8f50e44b45 #167: Rename examples/ to .examples/ 2021-10-24 15:20:39 -04:00
TwiN
fb2448c15a Omit fields that are not set 2021-10-24 15:03:41 -04:00
TwiN
db575aad13 Remove comments that no longer apply 2021-10-24 14:51:49 -04:00
TwiN
6ed93d4b82 Rename Service to Endpoint (#192)
* Add clarifications in comments

* #191: Rename Service to Endpoint
2021-10-23 16:47:12 -04:00
TwiN
634123d723 Add support for armv6 2021-10-18 12:16:31 -04:00
TwiN
75c25ac053 Don't support garbage browser versions 2021-10-08 00:21:29 -04:00
TwiN
8088736d6e Fix workflow 2021-10-07 22:42:37 -04:00
TwiN
6c45f5b99c ⚠ Migrate TwinProduction/gatus to TwiN/gatus 2021-10-07 21:28:04 -04:00
TwiN
422eaa6d37 Fix typo 2021-10-07 20:55:15 -04:00
TwinProduction
c423afb0bf Fix #182: Fix ICMP on Docker Linux 2021-10-07 01:21:13 -04:00
TwinProduction
835f768337 Shorten comment 2021-10-07 01:08:42 -04:00
TwinProduction
b3d0e54af2 Minor update 2021-10-07 01:08:17 -04:00
TwinProduction
1451cdfa64 Fix typo 2021-10-05 22:36:08 -04:00
TwinProduction
53cc9d88e5 Minor update 2021-10-05 20:44:18 -04:00
TwinProduction
a6bc0039e9 Rename integrations to overrides 2021-10-05 20:40:44 -04:00
achrefbensaadVPaccount
adbc2c5ad7 Add group-specific integration key for PagerDuty (#181)
* Added support for pagerduty integration per group

* Added pagerduty per group tests

* bugfix: if no team is provided and no general integration is provided return the first pagerduty integration in team integrations

* Updated README

* Update README.md

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

* Update alerting/provider/pagerduty/pagerduty.go

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

* Update alerting/provider/pagerduty/pagerduty.go

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

Co-authored-by: Achref Ben Saad <achref.bensaad@cimpress.com>
Co-authored-by: Chris <twin@twinnation.org>
2021-10-05 20:01:36 -04:00
TwinProduction
154bc7dbc6 Update dependencies 2021-10-03 22:15:20 -04:00
TwinProduction
2d3fe9795f Add v3 to module path
Gatus wasn't intended to be used as a library, but I have a use case now.
2021-10-03 21:53:59 -04:00
TwinProduction
d19f564e4e Ensure that tested hour never goes negative 2021-10-03 21:50:58 -04:00
TwinProduction
babe7b0be9 Update Go to 1.17 2021-10-03 15:03:09 -04:00
TwinProduction
dee04945d0 Minor update 2021-10-03 15:03:09 -04:00
TwinProduction
bf455fb7cc Fix issue with under maintenance feature 2021-10-01 02:33:37 -04:00
TwinProduction
dfd2f7943f Fix issue with privileged call on linux 2021-10-01 02:33:16 -04:00
TwinProduction
fece11540b Remove unnecessary rows.Close() calls 2021-09-30 21:19:57 -04:00
TwinProduction
ac43ef4ab7 Refactor some code 2021-09-30 20:56:09 -04:00
TwinProduction
bc25fea1c0 Minor improvements 2021-09-30 20:45:47 -04:00
Carlotronics
30cb7b6ec8 Health check for SSL/TLS services (#177)
* protocol: starttls: add timeout support

Signed-off-by: Charles Decoux <charles@phowork.fr>

* protocol: add ssl support

Signed-off-by: Charles Decoux <charles@phowork.fr>
2021-09-30 16:15:17 -04:00
TwinProduction
289d834587 Change domain 2021-09-28 18:54:40 -04:00
TwinProduction
428e415616 Fix issue with under maintenance 2021-09-27 00:11:42 -04:00
TwinProduction
0d284c2494 Update front-end dependencies 2021-09-25 13:39:54 -04:00
TwinProduction
4a46a5ae9e Rename security.go and security_test.go to config.go and config_test.go 2021-09-22 00:53:13 -04:00
TwinProduction
df3a2016ff Move web and ui configurations in their own packages 2021-09-22 00:47:51 -04:00
TwinProduction
dda83761b5 Minor fix 2021-09-22 00:11:46 -04:00
TwinProduction
882444e0d5 Minor fix 2021-09-22 00:11:22 -04:00
TwinProduction
fa4736c672 Close #74: Add maintenance window 2021-09-22 00:04:51 -04:00
TwinProduction
dc173b29bc Fix indention 2021-09-18 21:43:19 -04:00
TwinProduction
c3a4ce1eb4 Minor update 2021-09-18 13:04:50 -04:00
TwinProduction
044f0454f8 Domain migration 2021-09-18 12:42:11 -04:00
newsr
9bd5c38a96 Add enabled parameter to service (#175)
* feat: Add enabled flag to service
* Add IsEnabled method

Co-authored-by: 1newsr <1newsr@users.noreply.github.com>
2021-09-18 11:52:11 -04:00
TwinProduction
d6b4c2394a Update frontend dependencies 2021-09-16 22:47:03 -04:00
TwinProduction
9fe4678193 Update line separator 2021-09-16 22:35:22 -04:00
TwinProduction
f41560cd3e Add configuration for whether to resolve failed conditions or not 2021-09-14 19:34:46 -04:00
TwinProduction
d7de795a9f Retrieve metrics from the past 2 hours for badges with a duration of 1h 2021-09-13 23:29:35 -04:00
TwinProduction
f79e87844b Update diagram 2021-09-12 18:55:38 -04:00
TwinProduction
c57a930bf3 Refactor controller and handlers 2021-09-12 18:39:09 -04:00
TwinProduction
d86afb2381 Refactor handler errors 2021-09-12 17:06:14 -04:00
TwinProduction
d69df41ef0 Ensure connection to database by pinging it once before creating the schema 2021-09-11 22:42:56 -04:00
TwinProduction
cbfdc359d3 Postgres performance improvement 2021-09-11 17:49:31 -04:00
TwinProduction
f3822a949d Expose postgres port 2021-09-11 17:48:50 -04:00
TwinProduction
db5fc8bc11 #77: Make page logo customizable 2021-09-11 04:33:14 -04:00
TwinProduction
7a68920889 #77: Make page title customizable 2021-09-11 01:51:14 -04:00
TwinProduction
effad21c64 Uniformize docker-compose files 2021-09-10 20:03:51 -04:00
TwinProduction
dafd547656 Add example for using Postgres 2021-09-10 19:21:48 -04:00
TwinProduction
20487790ca Improve test coverage with edge cases made possible with Postgres 2021-09-10 19:01:44 -04:00
TwinProduction
b58094e10b Exclude storage/store/sql/specific_postgres.go from test coverage 2021-09-10 19:01:44 -04:00
TwinProduction
bacf7d841b Close #124: Add support for Postgres as a storage solution 2021-09-10 19:01:44 -04:00
TwinProduction
06ef7f9efe Add test for NewEventFromResult 2021-09-06 16:34:03 -04:00
TwinProduction
bfbe928173 Fix uptime badge 2021-09-06 15:06:30 -04:00
1480 changed files with 395555 additions and 65537 deletions

View File

@@ -1,4 +1,4 @@
examples
.examples
Dockerfile
.github
.idea

View File

@@ -1,16 +1,16 @@
metrics: true
services:
- name: TwiNNatioN
url: https://twinnation.org/health
endpoints:
- name: website
url: https://twin.sh/health
interval: 30s
conditions:
- "[STATUS] == 200"
- name: GitHub
- name: github
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- name: Example
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"

View File

@@ -1,12 +1,11 @@
version: '3.7'
version: "3.9"
services:
gatus:
container_name: gatus
image: twinproduction/gatus
restart: always
ports:
- 8080:8080
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
networks:
@@ -18,7 +17,7 @@ services:
restart: always
command: --config.file=/etc/prometheus/prometheus.yml
ports:
- 9090:9090
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
networks:
@@ -31,7 +30,7 @@ services:
environment:
GF_SECURITY_ADMIN_PASSWORD: secret
ports:
- 3000:3000
- "3000:3000"
volumes:
- ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro

View File

@@ -58,9 +58,9 @@
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks[30s])) by (service)",
"expr": "sum(rate(gatus_tasks[30s])) by (endpoint)",
"interval": "30s",
"legendFormat": "{{service}}",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
@@ -145,9 +145,9 @@
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (service)",
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (endpoint)",
"interval": "30s",
"legendFormat": "{{service}}",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
@@ -232,10 +232,10 @@
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (service)",
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (endpoint)",
"instant": false,
"interval": "30s",
"legendFormat": "{{service}}",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],

View File

@@ -3,9 +3,9 @@ alerting:
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
insecure: true
services:
endpoints:
- name: example
url: http://example.org
url: https://example.org
interval: 1m
alerts:
- type: mattermost

View File

@@ -1,11 +1,10 @@
version: "3.8"
version: "3.9"
services:
gatus:
container_name: gatus
image: twinproduction/gatus:latest
ports:
- 8080:8080
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
networks:
@@ -15,7 +14,7 @@ services:
container_name: mattermost
image: mattermost/mattermost-preview:5.26.0
ports:
- 8065:8065
- "8065:8065"
networks:
- default

View File

@@ -0,0 +1,42 @@
storage:
type: postgres
path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
endpoints:
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
- 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: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"

View File

@@ -0,0 +1,29 @@
version: "3.9"
services:
postgres:
image: postgres
volumes:
- ./data/db:/var/lib/postgresql/data
ports:
- "5432:5432"
environment:
- POSTGRES_DB=gatus
- POSTGRES_USER=username
- POSTGRES_PASSWORD=password
networks:
- web
gatus:
image: twinproduction/gatus:latest
restart: always
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
networks:
- web
depends_on:
- postgres
networks:
web:

View File

@@ -1,8 +1,8 @@
storage:
type: sqlite
file: /data/data.db
path: /data/data.db
services:
endpoints:
- name: back-end
group: core
url: "https://example.org/"

View File

@@ -1,9 +1,9 @@
version: "3.8"
version: "3.9"
services:
gatus:
image: twinproduction/gatus:latest
ports:
- 8080:8080
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
- ./data:/data/

View File

@@ -1,6 +1,6 @@
services:
endpoints:
- name: example
url: http://example.org
url: https://example.org
interval: 30s
conditions:
- "[STATUS] == 200"

View File

@@ -1,6 +1,6 @@
services:
endpoints:
- name: example
url: http://example.org
url: https://example.org
interval: 30s
conditions:
- "[STATUS] == 200"

View File

@@ -1,18 +1,25 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: gatus
namespace: kube-system
data:
config.yaml: |
metrics: true
services:
- name: TwiNNatioN
url: https://twinnation.org/health
interval: 1m
endpoints:
- name: website
url: https://twin.sh/health
interval: 5m
conditions:
- "[STATUS] == 200"
- name: GitHub
- "[BODY].status == UP"
- name: github
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- name: cat-fact
url: "https://cat-fact.herokuapp.com/facts/random"
interval: 5m
@@ -23,11 +30,14 @@ data:
- "[BODY].text == pat(*cat*)"
- "[STATUS] == pat(2*)"
- "[CONNECTED] == true"
- name: Example
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"
kind: ConfigMap
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: gatus
namespace: kube-system
@@ -41,14 +51,16 @@ spec:
replicas: 1
selector:
matchLabels:
k8s-app: gatus
app: gatus
template:
metadata:
labels:
k8s-app: gatus
app: gatus
name: gatus
namespace: kube-system
spec:
serviceAccountName: gatus
terminationGracePeriodSeconds: 5
containers:
- image: twinproduction/gatus
imagePullPolicy: IfNotPresent
@@ -84,4 +96,4 @@ spec:
protocol: TCP
targetPort: 8080
selector:
k8s-app: gatus
app: gatus

2
.gitattributes vendored
View File

@@ -1 +1 @@
* text=lf
* text=auto eol=lf

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
github: [TwinProduction]
github: [TwiN]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

BIN
.github/assets/dashboard-conditions.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
.github/assets/dashboard-dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1 +1 @@
<mxfile host="app.diagrams.net" modified="2021-07-30T04:27:17.723Z" agent="5.0 (Windows)" etag="hZKgW5ZLl0WUgYrCJXa4" version="14.9.2" type="device"><diagram id="1euQ5oT3BAcxlUCibzft" name="Page-1">7VvbcpswEP0aP6YDSPjy2MRum5lmptMkk+ZRARXUCtYV8oV+fUUsDLYSh07TLDP2k9FKWOKcw2q1EgNyka0/KjZPryDmchB48XpApoMg8IlHzE9lKTeWERlvDIkSsW3UGK7Fb26NnrUuRMyLnYYaQGox3zVGkOc80js2phSsdpt9B7nb65wl3DFcR0y61jsR63RjHYdeY//ERZLWPfuerclY3dgaipTFsGqZyGxALhSA3lxl6wsuK/BqXDb3fXimdjswxXPd5Yazebia3Z1ffuJfy+VwJm9K//ZsuPmXJZML+8B2sLqsEeCxAcQWQekUEsiZnDXWcwWLPOZVN54pNW0+A8yN0TfGH1zr0rLLFhqMKdWZtLWbPquOnn02aypgoSJ+4IFqjTCVcH2gXbhlwEiXQ8a1Ks19ikumxXJ3HMxqKNm2a2A2Fxbpv0Ddd1A3elNa5ImDfoNtBdQqFZpfz9kjBCvzxj2F49L8FV8fRtJ9cntDMLZyte+rX8t31ah/a0tbyh96/wksiqFIg5Yqv9n7Hwv3VeFdWBen63bltLSlV1Ry0FHJBFPJwfDEzkF2nnnb3oadeq7FYqch5L5d1x92yAj15XGmgRXTURoD/jSwDWPsNBCE2NMA8R1QjsPTkI5a3oRuaK7G0XKhQVXBNbaU9yMaMsaW8tjB6kilHXadRL2nGX6jaP1YY5yu9ExQ2Rmd2DnIDmqI409O7Bx2baiz9jY3daLnGXooalB1oucFelCTH5Sc6DlID0WN2+ixZg4704M699hRtlZBcwVLEXPl0PbCInF3RTn49yUjHfYtCT5ywLriRWGW1w9Cxeh4jfayRZNhN7iC/wXXxIHrWrLoZ++A8kNspOp91hZUX4ys1HShy/7BFaDD5eZsB8FQmm7PY7E0l0l1ecNZVtR200+rqn+YjtExdXOHV0xrrjIodO/wGo6w4aIOXDdc8kSxrHdgBT62uIJjTQ8F1lO9vG+AehQiONYEUXd+xqj8uBNetbGDv62zH6Pjb+sE7oqm+CUNDr1zyxTfLbt7YCLPeAaqf1EnGVNktAiyk9w9x+G9nZMko65OEtNHEnd1XnC1FBEvKiAqsxkF5Og+c/9UB35eg7rLz6koIuhhUqM+TIXmBag7Fd+shBTQO6gCGmJjhbxnj3byjXZN/uLunLihUgQGEpDyifQv9okhOkF3k8gHOdFWSbRrAEBRV7HUjQBuL9F1vD/d4+s4dKd7DGG/pkAnXQWKeg6nHmZboMUTrrZI2by6XGTyfWTW+QasSoIiMuiyBy6/QCEeQ1kyfQCtIWs1eC9FUlVo2JMyLLQUOb/YfqLlvZKfflne5HXUbYrNh1qPda3P3cjsDw==</diagram></mxfile>
<mxfile host="app.diagrams.net" modified="2021-09-12T22:49:28.336Z" agent="5.0 (Windows)" etag="r9FJ6Bphqwq-LaTO-jp3" version="15.0.6" type="device"><diagram id="FBbfVOMCjf6Z2LK8Yagy" name="Page-1">7Vtdb5swFP01edwEOCHJY5t03aR1q9RWbR9d8MCb4TJj8rFfP9OYBOI2YVrTi5S8RPjaxOack2sf7PTIJFlcSprFVxAy0fOccNEj057nDceu/iwDy1WADEarQCR5uAq5m8AN/8NM0DHRgocsbzRUAELxrBkMIE1ZoBoxKiXMm81+gGj2mtGIWYGbgAo7es9DFZuo5zibis+MR3HV9dgzNQmtWptAHtMQ5rUQueiRiQRQq6tkMWGiBK8CZnXfp1dq1yOTLFVtbvj2/UGwZFq4GpXZfXYJ8Yh9MN8yo6IwT2wGq5YVBCzUiJgiSBVDBCkVF5vouYQiDVnZjaNLmzZfATIddHXwJ1NqaeilhQIdilUiTO2qz7KjV5/NhHIoZMB2PJAZv6IyYmrXgw/XFGjtMkiYkkt9o2SCKj5rDoQaFUXrdhuc9YWB+h9g9yzYteKk4mlkwb8Bt0RqHnPFbjL6jMFc/+ReAnKmv4otdkNpP7m5YTQ2ejW/WM8xg5039L+KxTXp+86BwCIYktRoyeWDuf+58FgWPg6q4nRRr5wuTekNpey3lPIIU8n9Ezk7yfEwyRngkrPh47Fe1x1yiIPJjm9NAnOqgjgE/Elg6DYnAUKwJ4HhkeaZUds842NKeWRJOVcgy7U1tpK3lzN9H1vJYwurI5W2O2yrbdQ0XaXCEz+vNhyj8uOe+NnDD6pDcE/5bV9+e2UKeyd+jtVft+eHoPJzrBa7PT+oJttFdtnd54fg5jf/xM8efnB/P2aYNUOUSZjxkEmLuD1+sWkue//vHl1n2LW34a7tta9Ynmuv/cRliI8Y2Xp15A5H7RDzDoaY7bhvBA1+dQ8rj2BjVX1xDatrLS05LdSyg3g56Hi9tIHqC93techn+jIqL28ZTfIqrvupVXUQVB8dVM9OcVQpJhPIVfcAc/tjbMCIBdgtEyySNOkeXN4YXV/H6qi81ruWuKcjjtVRtecHd8//WN+It+aHoL4R9+yd5XI7Dn8zzrJT+Ltxnu0+899CA9G9ibM/agnW4SZO23vyNGEJyA6ag/7AwYZrjJsnm+dvnPfLk8RpmSdRDy0Q2+vmTM54wPISiDKsRwEpetrcPo2D/xKK2LZ3yvMAZNi9PLBGBi0PVIen6vZszgWH7oFFqvkQDyzk7Ui0Q4uk3zJpoq79q1HWtByAhgSEeOF9PfZpr8EQPVMeq5clrb1SH1XPtle6+4Ku4+0ZvwM6tn0ShrDfUqBtj9YS3FWq7bnu8hdSbR7TrLwsEnEWaLevwSolyAONLn1i4hpy/ryaJdMnUAqSWoMzwaOyQsGWlKFQgqdssv53nfM2+vbJfn2Td5W3vUeYQa4iWXqBri3SBtWRrQMs0nRx85fE57raHzvJxV8=</diagram></mxfile>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
.github/assets/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

1
.github/codecov.yml vendored
View File

@@ -1,5 +1,6 @@
ignore:
- "watchdog/watchdog.go"
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
coverage:
status:

View File

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

View File

@@ -14,7 +14,7 @@ jobs:
- name: Check out code
uses: actions/checkout@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

View File

@@ -11,7 +11,7 @@ jobs:
- name: Check out code
uses: actions/checkout@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Set up QEMU
@@ -26,7 +26,7 @@ jobs:
- name: Build and push docker image
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 TwiN
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

519
README.md
View File

@@ -1,30 +1,32 @@
![Gatus](.github/assets/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)
[![codecov](https://codecov.io/gh/TwinProduction/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwinProduction/gatus)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwinProduction/gatus.svg)](https://github.com/TwinProduction/gatus)
![build](https://github.com/TwiN/gatus/workflows/build/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus)
[![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus)
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
[![Join Discord server](https://img.shields.io/discord/442432928614449155.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/TDfjeHmXDQ)
[![Follow TwinProduction](https://img.shields.io/github/followers/TwinProduction?label=Follow&style=social)](https://github.com/TwinProduction)
[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)
Gatus is a health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
checks can be paired with alerting via Slack, PagerDuty, Discord and even Twilio.
checks can be paired with alerting via Slack, PagerDuty, Discord, Twilio and more.
I personally deploy it in my Kubernetes cluster and let it monitor the status of my
core applications: https://status.twinnation.org/
core applications: https://status.twin.sh/
<details>
<summary><b>Quick start</b></summary>
```
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus
```
For more details, see [Usage](#usage)
</details>
![Gatus dashboard conditions](.github/assets/dashboard-conditions.png)
Have any feedback or want to share your good/bad experience with Gatus? Feel free to email me at [feedback@gatus.io](mailto:feedback@gatus.io)
## Table of Contents
- [Why Gatus?](#why-gatus)
@@ -38,6 +40,7 @@ For more details, see [Usage](#usage)
- [Client configuration](#client-configuration)
- [Alerting](#alerting)
- [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
@@ -47,6 +50,7 @@ For more details, see [Usage](#usage)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance)
- [Deployment](#deployment)
- [Docker](#docker)
- [Helm Chart](#helm-chart)
@@ -57,14 +61,15 @@ For more details, see [Usage](#usage)
- [Sending a GraphQL request](#sending-a-graphql-request)
- [Recommended interval](#recommended-interval)
- [Default timeouts](#default-timeouts)
- [Monitoring a TCP service](#monitoring-a-tcp-service)
- [Monitoring a service using ICMP](#monitoring-a-service-using-icmp)
- [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries)
- [Monitoring a service using STARTTLS](#monitoring-a-service-using-starttls)
- [Monitoring a TCP endpoint](#monitoring-a-tcp-endpoint)
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
- [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
- [Basic authentication](#basic-authentication)
- [disable-monitoring-lock](#disable-monitoring-lock)
- [Reloading configuration on the fly](#reloading-configuration-on-the-fly)
- [Service groups](#service-groups)
- [Endpoint groups](#endpoint-groups)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Badges](#badges)
- [Uptime](#uptime)
@@ -92,8 +97,6 @@ fixing the issue before they even know about it.
## Features
![Gatus dark mode](.github/assets/dark-mode.png)
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.
@@ -101,8 +104,10 @@ The main features of Gatus are:
- **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, Twilio and Teams 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.
- **[Badges](#badges)**: ![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/core_website-external.svg) ![Response time 24h](https://status.twinnation.org/api/v1/services/core_website-external/response-times/24h/badge.svg)
- **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
- **Dark mode**
![Gatus dashboard dark mode](.github/assets/dashboard-dark.png)
## Usage
By default, the configuration file is expected to be at `config/config.yaml`.
@@ -110,25 +115,23 @@ By default, the configuration file is expected to be at `config/config.yaml`.
You can specify a custom path by setting the `GATUS_CONFIG_FILE` environment variable.
Here's a simple example:
```yaml
metrics: true # Whether to expose metrics at /metrics
services:
- name: twinnation # Name of your service, can be anything
url: "https://twinnation.org/health"
interval: 30s # Duration to wait between every status check (default: 60s)
endpoints:
- name: website # Name of your endpoint, can be anything
url: "https://twin.sh/health"
interval: 5m # Duration to wait between every status check (default: 60s)
conditions:
- "[STATUS] == 200" # Status must be 200
- "[BODY].status == UP" # The json path "$.status" must be equal to UP
- "[RESPONSE_TIME] < 300" # Response time must be under 300ms
- name: example
url: "https://example.org/"
interval: 5m
interval: 60s
conditions:
- "[STATUS] == 200"
```
This example would look like this:
This example would look similar to this:
![Simple example](.github/assets/example.png)
@@ -142,30 +145,32 @@ If you want to test it locally, see [Docker](#docker).
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `debug` | Whether to enable debug logs. | `false` |
| `metrics` | Whether to expose metrics at /metrics. | `false` |
| `storage` | Storage configuration. <br />See [Storage](#storage). | `{}` |
| `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. <br />See [Service groups](#service-groups). | `""` |
| `services[].url` | URL to send the request to. | Required `""` |
| `services[].method` | Request method. | `GET` |
| `services[].conditions` | Conditions used to determine the health of the service. <br />See [Conditions](#conditions). | `[]` |
| `services[].interval` | Duration to wait between every status check. | `60s` |
| `services[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
| `services[].body` | Request body. | `""` |
| `services[].headers` | Request headers. | `{}` |
| `services[].dns` | Configuration for a service of type DNS. <br />See [Monitoring a service using DNS queries](#monitoring-a-service-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`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `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` |
| `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `services[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `services[].client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `services[].ui` | UI configuration. | `{}` |
| `services[].ui.hide-hostname` | Whether to include the hostname in the result. | `false` |
| `alerting` | Configuration for alerting. <br />See [Alerting](#alerting). | `{}` |
| `storage` | [Storage configuration](#storage) | `{}` |
| `endpoints` | List of endpoints to monitor. | Required `[]` |
| `endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
| `endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
| `endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
| `endpoints[].url` | URL to send the request to. | Required `""` |
| `endpoints[].method` | Request method. | `GET` |
| `endpoints[].conditions` | Conditions used to determine the health of the endpoint. <br />See [Conditions](#conditions). | `[]` |
| `endpoints[].interval` | Duration to wait between every status check. | `60s` |
| `endpoints[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
| `endpoints[].body` | Request body. | `""` |
| `endpoints[].headers` | Request headers. | `{}` |
| `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
| `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` |
| `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` |
| `endpoints[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `email`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
| `endpoints[].alerts[].enabled` | Whether to enable the alert. | `false` |
| `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `endpoints[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `endpoints[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
| `endpoints[].ui.hide-hostname` | Whether to include the hostname in the result. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `security` | Security configuration. | `{}` |
| `security.basic` | Basic authentication security configuration. | `{}` |
| `security.basic.username` | Username for Basic authentication. | Required `""` |
@@ -175,6 +180,9 @@ If you want to test it locally, see [Docker](#docker).
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | Title of the page. | `Health Dashboard ǀ Gatus` |
| `ui.logo` | URL to the logo to display | `""` |
### Conditions
@@ -187,7 +195,7 @@ Here are some examples of conditions you can use:
| `[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, ... |
| `[STATUS] == any(200, 429)` | Status must be either 200 or 429 | 200, 429 | 201, 400, ... |
| `[CONNECTED] == true` | Connection to host must've been successful | true, false | |
| `[CONNECTED] == true` | Connection to host must've been successful | true | false |
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 501ms |
| `[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`, ... |
@@ -211,7 +219,7 @@ Here are some examples of conditions you can use:
| `[IP]` | Resolves into the IP of the target host | 192.168.0.232
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}`
| `[CONNECTED]` | Resolves into whether a connection could be established | `true`
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not using HTTPS)
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not protocol with certs)
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR
@@ -230,22 +238,35 @@ Here are some examples of conditions you can use:
| Parameter | Description | Default |
|:------------------ |:-------------------------------------------------------------------------------------- |:-------------- |
| `storage` | Storage configuration | `{}` |
| `storage.file` | File to persist the data in. If the type is `memory`, data is persisted on interval. | `""` |
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`. | `"memory"` |
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
- If `storage.type` is `memory` (default) and `storage.file` is set to a non-blank value.
Furthermore, the data is periodically persisted, but everything remains in memory.
- If `storage.type` is `sqlite`, `storage.file` must not be blank.
- If `storage.type` is `memory` (default):
```yaml
# Note that this is the default value, and you can omit the storage configuration altogether to achieve the same result.
# Because the data is stored in memory, the data will not survive a restart.
storage:
type: memory
```
- If `storage.type` is `sqlite`, `storage.path` must not be blank:
```yaml
storage:
type: sqlite
file: data.db
path: data.db
```
See [examples/docker-compose-sqlite-storage](examples/docker-compose-sqlite-storage) for an example.
See [examples/docker-compose-sqlite-storage](.examples/docker-compose-sqlite-storage) for an example.
- If `storage.type` is `postgres`, `storage.path` must be the connection URL:
```yaml
storage:
type: postgres
path: "postgres://user:password@127.0.0.1:5432/gatus?sslmode=disable"
```
See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres-storage) for an example.
### Client configuration
In order to support a wide range of environments, each monitored service has a unique configuration for
In order to support a wide range of environments, each monitored endpoint has a unique configuration for
the client used to send the request.
| Parameter | Description | Default |
@@ -254,8 +275,8 @@ the client used to send the request.
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` |
Note that some of these parameters are ignored based on the type of service. For instance, there's no certificate involved
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for a service of that type will not do anything.
Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.
This default configuration is as follows:
```yaml
@@ -264,13 +285,13 @@ client:
ignore-redirect: false
timeout: 10s
```
Note that this configuration is only available under `services[]`, `alerting.mattermost` and `alerting.custom`.
Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`.
Here's an example with the client configuration under `service[]`:
Here's an example with the client configuration under `endpoints[]`:
```yaml
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
client:
insecure: false
ignore-redirect: false
@@ -282,7 +303,7 @@ services:
### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
individual services with configurable descriptions and thresholds.
individual endpoints with configurable descriptions and thresholds.
Note that if an alerting provider is not properly configured, all alerts configured with the provider's type will be
ignored.
@@ -312,9 +333,9 @@ alerting:
discord:
webhook-url: "https://discord.com/api/webhooks/**********/**********"
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
@@ -328,6 +349,44 @@ services:
```
#### Configuring Email alerts
| Parameter | Description | Default |
|:---------------------------------- |:------------------------------------------------- |:-------------- |
| `alerting.email` | Configuration for alerts of type `email` | `{}` |
| `alerting.email.from` | Email used to send the alert | Required `""` |
| `alerting.email.password` | Password of the email used to send the alert | Required `""` |
| `alerting.email.host` | Host of the mail server (e.g. `smtp.gmail.com`) | Required `""` |
| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` |
| `alerting.email.to` | Email(s) to send the alerts to | Required `""` |
| `alerting.email.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
email:
from: "from@example.com"
password: "hunter2"
host: "mail.example.com"
port: 587
to: "recipient1@example.com,recipient2@example.com"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: email
enabled: true
description: "healthcheck failed"
send-on-resolved: true
```
**NOTE:** Some mail servers are painfully slow.
#### Configuring Mattermost alerts
| Parameter | Description | Default |
|:----------------------------------- |:------------------------------------------------------------------------------------------- |:-------------- |
@@ -343,9 +402,9 @@ alerting:
client:
insecure: true
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
@@ -379,10 +438,11 @@ alerting:
access-key: "..."
originator: "31619191918"
recipients: "31619191919,31619191920"
services:
- name: twinnation
endpoints:
- name: website
interval: 30s
url: "https://twinnation.org/health"
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
@@ -398,24 +458,37 @@ services:
#### Configuring PagerDuty alerts
| Parameter | Description | Default |
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|:------------------------------------------------------ |:----------------------------------------------------------------------------- |:-------------- |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key. | Required `""` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` |
It is highly recommended to set `services[].alerts[].send-on-resolved` to `true` for alerts
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
parameter to `true` will not create another incident, but mark the incident as resolved on
PagerDuty instead.
Behavior:
- By default, `alerting.pagerduty.integration-key` is used as the integration key
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.pagerduty.overrides[].group`, the provider will use that override's integration key instead of `alerting.pagerduty.integration-key`'s
```yaml
alerting:
pagerduty:
integration-key: "********************************"
# You can also add group-specific integration keys, which will
# override the integration key above for the specified groups
overrides:
- group: "core"
integration-key: "********************************"
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
@@ -428,6 +501,21 @@ services:
success-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: pagerduty
enabled: true
failure-threshold: 3
success-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
@@ -443,9 +531,9 @@ alerting:
slack:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
@@ -480,9 +568,9 @@ alerting:
teams:
webhook-url: "https://********.webhook.office.com/webhookb2/************"
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
@@ -513,9 +601,9 @@ alerting:
token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
id: "0123456789"
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
@@ -549,10 +637,10 @@ alerting:
from: "+1-234-567-8901"
to: "+1-234-567-8901"
services:
- name: twinnation
endpoints:
- name: website
interval: 30s
url: "https://twinnation.org/health"
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
@@ -580,12 +668,12 @@ services:
While they're called alerts, you can use this feature to call anything.
For instance, you could automate rollbacks by having an application that keeps tracks of new deployments, and by
leveraging Gatus, you could have Gatus call that application endpoint when a service starts failing. Your application
would then check if the service that started failing was recently deployed, and if it was, then automatically
roll it back.
leveraging Gatus, you could have Gatus call that application endpoint when an endpoint starts failing. Your application
would then check if the endpoint that started failing was part of the recently deployed application, and if it was,
then automatically roll it back.
The placeholders `[ALERT_DESCRIPTION]` and `[SERVICE_NAME]` are automatically substituted for the alert description and
the service name. These placeholders can be used in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`).
The placeholders `[ALERT_DESCRIPTION]` and `[ENDPOINT_NAME]` are automatically substituted for the alert description and
the endpoint name. These placeholders can be used in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`).
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
@@ -600,11 +688,11 @@ alerting:
method: "POST"
body: |
{
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
}
services:
- name: twinnation
url: "https://twinnation.org/health"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
@@ -641,7 +729,7 @@ As a result, the `[ALERT_TRIGGERED_OR_RESOLVED]` in the body of first example of
| `alerting.*.default-alert.send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved | N/A |
| `alerting.*.default-alert.description` | Description of the alert. Will be included in the alert sent | N/A |
While you can specify the alert configuration directly in the service definition, it's tedious and may lead to a very
While you can specify the alert configuration directly in the endpoint definition, it's tedious and may lead to a very
long configuration file.
To avoid such problem, you can use the `default-alert` parameter present in each provider configuration:
@@ -657,9 +745,9 @@ alerting:
success-threshold: 5
```
As a result, your service configuration looks a lot tidier:
As a result, your Gatus configuration looks a lot tidier:
```yaml
services:
endpoints:
- name: example
url: "https://example.org"
conditions:
@@ -677,7 +765,7 @@ services:
It also allows you to do things like this:
```yaml
services:
endpoints:
- name: example
url: "https://example.org"
conditions:
@@ -705,8 +793,8 @@ alerting:
enabled: true
failure-threshold: 5
services:
- name: service-1
endpoints:
- name: endpoint-1
url: "https://example.org"
conditions:
- "[STATUS] == 200"
@@ -714,7 +802,7 @@ services:
- type: slack
- type: pagerduty
- name: service-2
- name: endpoint-2
url: "https://example.org"
conditions:
- "[STATUS] == 200"
@@ -723,31 +811,62 @@ services:
- type: pagerduty
```
### Maintenance
If you have maintenance windows, you may not want to be annoyed by alerts.
To do that, you'll have to use the maintenance configuration:
| Parameter | Description | Default |
|:----------------------- |:----------------------------------------------------------------------------- |:--------------- |
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
**Note that the maintenance configuration uses UTC.**
Here's an example:
```yaml
maintenance:
start: 23:00
duration: 1h
every: [Monday, Thursday]
```
Note that you can also specify each day on separate lines:
```yaml
maintenance:
start: 23:00
duration: 1h
every:
- Monday
- Thursday
```
## Deployment
Many examples can be found in the [examples](examples) folder, but this section will focus on the most popular ways of deploying Gatus.
Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
### Docker
To run Gatus locally with Docker:
```
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus
```
Other than using one of the examples provided in the `examples` folder, you can also try it out locally by
Other than using one of the examples provided in the [.examples](.examples) folder, you can also try it out locally by
creating a configuration file, we'll call it `config.yaml` for this example, and running the following
command:
```
```console
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.:
```
```console
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
```
To build the image locally:
```
```console
docker build . -t twinproduction/gatus
```
@@ -767,12 +886,12 @@ and [helmfile example](https://github.com/avakarev/gatus-chart#helmfileyaml-exam
### Terraform
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwinProduction/terraform-kubernetes-gatus).
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
## Running the tests
```
```console
go test ./... -mod vendor
```
@@ -783,11 +902,11 @@ See the [Deployment](#deployment) section.
## FAQ
### Sending a GraphQL request
By setting `services[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter.
By setting `endpoints[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter.
For instance, the following configuration:
```yaml
services:
endpoints:
- name: filter-users-by-gender
url: http://localhost:8080/playground
method: POST
@@ -813,28 +932,27 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
### Recommended interval
> **NOTE**: This does not _really_ apply if `disable-monitoring-lock` is set to `true`, as the monitoring lock is what
> tells Gatus to only evaluate one service at a time.
> **NOTE**: This does not apply if `disable-monitoring-lock` is set to `true`, as the monitoring lock is what
> tells Gatus to only evaluate one endpoint at a time.
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus only evaluates one service at a time
In other words, even if you have multiple services with the exact same interval, they will not execute at the same time.
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus only evaluates one endpoint at a time
In other words, even if you have multiple endpoints with the exact same interval, they will not execute at the same time.
You can test this yourself by running Gatus with several services configured with a very short, unrealistic interval,
such as 1ms. You'll notice that the response time does not fluctuate - that is because while services are evaluated on
different goroutines, there's a global lock that prevents multiple services from running at the same time.
You can test this yourself by running Gatus with several endpoints configured with a very short, unrealistic interval,
such as 1ms. You'll notice that the response time does not fluctuate - that is because while endpoints are evaluated on
different goroutines, there's a global lock that prevents multiple endpoints from running at the same time.
Unfortunately, there is a drawback. If you have a lot of services, including some that are very slow or prone to time out (the default
timeout is 10s), then it means that for the entire duration of the request, no other services can be evaluated.
Unfortunately, there is a drawback. If you have a lot of endpoints, including some that are very slow or prone to timing out
(the default timeout is 10s), then it means that for the entire duration of the request, no other endpoint can be evaluated.
**This does mean that Gatus will be unable to evaluate the health of other services**.
The interval does not include the duration of the request itself, which means that if a service has an interval of 30s
The interval does not include the duration of the request itself, which means that if an endpoint has an interval of 30s
and the request takes 2s to complete, the timestamp between two evaluations will be 32s, not 30s.
While this does not prevent Gatus' from performing health checks on all other services, it may cause Gatus to be unable
While this does not prevent Gatus' from performing health checks on all other endpoints, it may cause Gatus to be unable
to respect the configured interval, for instance:
- Service A has an interval of 5s, and times out after 10s to complete
- Service B has an interval of 5s, and takes 1ms to complete
- Service B will be unable to run every 5s, because service A's health evaluation takes longer than its interval
- Endpoint A has an interval of 5s, and times out after 10s to complete
- Endpoint B has an interval of 5s, and takes 1ms to complete
- Endpoint B will be unable to run every 5s, because endpoint A's health evaluation takes longer than its interval
To sum it up, while Gatus can really handle any interval you throw at it, you're better off having slow requests with
higher interval.
@@ -844,8 +962,8 @@ simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
### Default timeouts
| Protocol | Timeout |
|:-------- |:------- |
| Endpoint type | Timeout |
|:------------- |:------- |
| HTTP | 10s
| TCP | 10s
| ICMP | 10s
@@ -853,11 +971,11 @@ simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
To modify the timeout, see [Client configuration](#client-configuration).
### Monitoring a TCP service
By prefixing `services[].url` with `tcp:\\`, you can monitor TCP services at a very basic level:
### Monitoring a TCP endpoint
By prefixing `endpoints[].url` with `tcp:\\`, you can monitor TCP endpoints at a very basic level:
```yaml
services:
endpoints:
- name: redis
url: "tcp://127.0.0.1:6379"
interval: 30s
@@ -865,34 +983,34 @@ services:
- "[CONNECTED] == true"
```
Placeholders `[STATUS]` and `[BODY]` as well as the fields `services[].body`, `services[].insecure`,
`services[].headers`, `services[].method` and `services[].graphql` are not supported for TCP services.
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
**NOTE**: `[CONNECTED] == true` does not guarantee that the service itself is healthy - it only guarantees that there's
**NOTE**: `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's
something at the given address listening to the given port, and that a connection to that address was successfully
established.
### Monitoring a service using ICMP
By prefixing `services[].url` with `icmp:\\`, you can monitor services at a very basic level using ICMP, or more
### Monitoring an endpoint using ICMP
By prefixing `endpoints[].url` with `icmp:\\`, you can monitor endpoints at a very basic level using ICMP, or more
commonly known as "ping" or "echo":
```yaml
services:
endpoints:
- name: ping-example
url: "icmp://example.com"
conditions:
- "[CONNECTED] == true"
```
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for services of type ICMP.
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.
You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `icmp://`.
### Monitoring a service using DNS queries
Defining a `dns` configuration in a service will automatically mark that service as a service of type DNS:
### Monitoring an endpoint using DNS queries
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
```yaml
services:
endpoints:
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 30s
@@ -904,20 +1022,37 @@ services:
- "[DNS_RCODE] == NOERROR"
```
There are two placeholders that can be used in the conditions for services of type DNS:
There are two placeholders that can be used in the conditions for endpoints of type DNS:
- The placeholder `[BODY]` resolves to the output of the query. For instance, a query of type `A` would return an IPv4.
- The placeholder `[DNS_RCODE]` resolves to the name associated to the response code returned by the query, such as
`NOERROR`, `FORMERR`, `SERVFAIL`, `NXDOMAIN`, etc.
### Monitoring a service using STARTTLS
### Monitoring an endpoint using STARTTLS
If you have an email server that you want to ensure there are no problems with, monitoring it through STARTTLS
will serve as a good initial indicator:
```yaml
services:
endpoints:
- name: starttls-smtp-example
url: "starttls://smtp.gmail.com:587"
interval: 30m
client:
timeout: 5s
conditions:
- "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h"
```
### Monitoring an endpoint using TLS
Monitoring endpoints using SSL/TLS encryption, such as LDAP over TLS, can help detect certificate expiration:
```yaml
endpoints:
- name: tls-ldaps-example
url: "tls://ldap.example.com:636"
interval: 30m
client:
timeout: 5s
conditions:
- "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h"
@@ -937,16 +1072,16 @@ The example above will require that you authenticate with the username `john.doe
### disable-monitoring-lock
Setting `disable-monitoring-lock` to `true` means that multiple services could be monitored at the same time.
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time.
While this behavior wouldn't generally be harmful, conditions using the `[RESPONSE_TIME]` placeholder could be impacted
by the evaluation of multiple services at the same time, therefore, the default value for this parameter is `false`.
by the evaluation of multiple endpoints at the same time, therefore, the default value for this parameter is `false`.
There are three main reasons why you might want to disable the monitoring lock:
- You're using Gatus for load testing (each services are periodically evaluated on a different goroutine, so
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)
- You're using Gatus for load testing (each endpoint are periodically evaluated on a different goroutine, so
technically, if you create 100 endpoints with a 1 seconds interval, Gatus will send 100 requests per second)
- You have a _lot_ of endpoints to monitor
- You want to test multiple endpoints at very short interval (< 5s)
### Reloading configuration on the fly
@@ -971,14 +1106,14 @@ to make.
**If you are not using a file storage**, updating the configuration while Gatus is running is effectively
the same as restarting the application.
**NOTE:** Updates may not be detected if the config file is bound instead of the config folder. See [#151](https://github.com/TwinProduction/gatus/issues/151).
**NOTE:** Updates may not be detected if the config file is bound instead of the config folder. See [#151](https://github.com/TwiN/gatus/issues/151).
### Service groups
Service groups are used for grouping multiple services together on the dashboard.
### Endpoint groups
Endpoint groups are used for grouping multiple endpoints together on the dashboard.
```yaml
services:
endpoints:
- name: frontend
group: core
url: "https://example.org/"
@@ -1007,7 +1142,7 @@ services:
conditions:
- "[STATUS] == 200"
- name: random service that isn't part of a group
- name: random endpoint that isn't part of a group
url: "https://example.org/"
interval: 5m
conditions:
@@ -1016,7 +1151,7 @@ services:
The configuration above will result in a dashboard that looks like this:
![Gatus Service Groups](.github/assets/service-groups.png)
![Gatus Endpoint Groups](.github/assets/endpoint-groups.png)
### Exposing Gatus on a custom port
@@ -1035,66 +1170,66 @@ web:
### Badges
### Uptime
![Uptime 1h](https://status.twinnation.org/api/v1/services/core_website-external/uptimes/1h/badge.svg)
![Uptime 24h](https://status.twinnation.org/api/v1/services/core_website-external/uptimes/24h/badge.svg)
![Uptime 7d](https://status.twinnation.org/api/v1/services/core_website-external/uptimes/7d/badge.svg)
![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg)
![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)
![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg)
Gatus can automatically generate a SVG badge for one of your monitored services.
This allows you to put badges in your individual services' README or even create your own status page, if you
Gatus can automatically generate a SVG badge for one of your monitored endpoints.
This allows you to put badges in your individual applications' README or even create your own status page, if you
desire.
The endpoint to generate a badge is the following:
The path to generate a badge is the following:
```
/api/v1/services/{key}/uptimes/{duration}/badge.svg
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
```
Where:
- `{duration}` is `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<SERVICE_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the uptime during the last 24 hours from the service `frontend` in the group `core`,
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
the URL would look like this:
```
https://example.com/api/v1/services/core_frontend/uptimes/7d/badge.svg
https://example.com/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg
```
If you want to display a service that is not part of a group, you must leave the group value empty:
If you want to display an endpoint that is not part of a group, you must leave the group value empty:
```
https://example.com/api/v1/services/_frontend/uptimes/7d/badge.svg
https://example.com/api/v1/endpoints/_frontend/uptimes/7d/badge.svg
```
Example:
```
![Uptime 24h](https://status.twinnation.org/api/v1/services/core_website-external/uptimes/24h/badge.svg)
![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)
```
If you'd like to see a visual example of each badges available, you can simply navigate to the service's detail page.
If you'd like to see a visual example of each badges available, you can simply navigate to the endpoint's detail page.
### Response time
![Response time 1h](https://status.twinnation.org/api/v1/services/core_website-external/response-times/1h/badge.svg)
![Response time 24h](https://status.twinnation.org/api/v1/services/core_website-external/response-times/24h/badge.svg)
![Response time 7d](https://status.twinnation.org/api/v1/services/core_website-external/response-times/7d/badge.svg)
![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/badge.svg)
The endpoint to generate a badge is the following:
```
/api/v1/services/{key}/response-times/{duration}/badge.svg
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg
```
Where:
- `{duration}` is `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<SERVICE_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
### API
Gatus provides a simple read-only API which can be queried in order to programmatically determine service status and history.
Gatus provides a simple read-only API which can be queried in order to programmatically determine endpoint status and history.
All services are available via a GET request to the following endpoint:
All endpoints are available via a GET request to the following endpoint:
```
/api/v1/services/statuses
/api/v1/endpoints/statuses
````
Example: https://status.twinnation.org/api/v1/services/statuses
Example: https://status.twin.sh/api/v1/endpoints/statuses
Specific services can also be queried by using the following pattern:
Specific endpoints can also be queried by using the following pattern:
```
/api/v1/services/{group}_{service}/statuses
/api/v1/endpoints/{group}_{endpoint}/statuses
```
Example: https://status.twinnation.org/api/v1/services/core_website-home/statuses
Example: https://status.twin.sh/api/v1/endpoints/core_blog-home/statuses
Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzip`.
@@ -1107,7 +1242,7 @@ No such header is required to query the API.
## Sponsors
You can find the full list of sponsors [here](https://github.com/sponsors/TwinProduction).
You can find the full list of sponsors [here](https://github.com/sponsors/TwiN).
[<img src="https://github.com/math280h.png" width="50" />](https://github.com/math280h)
[<img src="https://github.com/pyroscope-io.png" width="50" />](https://github.com/pyroscope-io)

View File

@@ -1,15 +1,15 @@
package alert
// Alert is the service's alert configuration
// Alert is a core.Endpoint's alert configuration
type Alert struct {
// Type of alert (required)
Type Type `yaml:"type"`
// Enabled defines whether or not the alert is enabled
// Enabled defines whether the alert is enabled
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work.
Enabled *bool `yaml:"enabled"`
Enabled *bool `yaml:"enabled,omitempty"`
// FailureThreshold is the number of failures in a row needed before triggering the alert
FailureThreshold int `yaml:"failure-threshold"`
@@ -31,7 +31,7 @@ type Alert struct {
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents
ResolveKey string
ResolveKey string `yaml:"-"`
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
// should be set back to false. It is used to prevent the same alert from going out twice.
@@ -41,7 +41,7 @@ type Alert struct {
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
// some reason, the alert provider always returns errors when trying to send the resolved notification
// (SendOnResolved).
Triggered bool
Triggered bool `yaml:"-"`
}
// GetDescription retrieves the description of the alert

View File

@@ -11,6 +11,9 @@ const (
// TypeDiscord is the Type for the discord alerting provider
TypeDiscord Type = "discord"
// TypeEmail is the Type for the email alerting provider
TypeEmail Type = "email"
// TypeMattermost is the Type for the mattermost alerting provider
TypeMattermost Type = "mattermost"

View File

@@ -1,47 +1,51 @@
package alerting
import (
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/alerting/provider/discord"
"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/teams"
"github.com/TwinProduction/gatus/alerting/provider/telegram"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
)
// Config is the configuration for alerting providers
type Config struct {
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom"`
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
// Discord is the configuration for the discord alerting provider
Discord *discord.AlertProvider `yaml:"discord"`
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// Mattermost is the configuration for the mattermost alerting provider
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
// PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
// Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack"`
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams"`
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram"`
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio"`
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -59,6 +63,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil
}
return config.Discord
case alert.TypeEmail:
if config.Email == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Email
case alert.TypeMattermost:
if config.Mattermost == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil

View File

@@ -2,16 +2,14 @@ package custom
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"strings"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/client"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -26,7 +24,7 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -38,11 +36,6 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.URL) > 0 && provider.ClientConfig != nil
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
return provider
}
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
status := "TRIGGERED"
@@ -57,7 +50,7 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status
}
func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription string, resolved bool) *http.Request {
func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request {
body := provider.Body
providerURL := provider.URL
method := provider.Method
@@ -65,8 +58,11 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
if strings.Contains(body, "[ALERT_DESCRIPTION]") {
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(body, "[SERVICE_NAME]") {
body = strings.ReplaceAll(body, "[SERVICE_NAME]", serviceName)
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(body, "[ENDPOINT_NAME]") {
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
}
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
if resolved {
@@ -78,8 +74,11 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(providerURL, "[SERVICE_NAME]") {
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", serviceName)
if strings.Contains(providerURL, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(providerURL, "[ENDPOINT_NAME]") {
providerURL = strings.ReplaceAll(providerURL, "[ENDPOINT_NAME]", endpointName)
}
if strings.Contains(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]") {
if resolved {
@@ -99,27 +98,17 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
return request
}
// Send a request to the alert provider and return the body
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
return nil, errors.New("error")
}
return []byte("{}"), nil
}
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return nil, err
return err
}
if response.StatusCode > 399 {
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("call to provider alert returned status code %d", response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return ioutil.ReadAll(response.Body)
return err
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -2,10 +2,13 @@ package custom
import (
"io/ioutil"
"net/http"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -13,23 +16,100 @@ func TestAlertProvider_IsValid(t *testing.T) {
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{URL: "http://example.com"}
validProvider := AlertProvider{URL: "https://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
const (
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
ExpectedBody = "service-name,alert-description,RESOLVED"
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,RESOLVED"
)
customAlertProvider := &AlertProvider{
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
}
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
@@ -41,15 +121,15 @@ func TestAlertProvider_buildHTTPRequestWhenResolved(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"
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
customAlertProvider := &AlertProvider{
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: map[string]string{"Authorization": "Basic hunter2"},
}
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", false)
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
@@ -59,25 +139,14 @@ func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProvider(t *testing.T) {
provider := AlertProvider{URL: "http://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if customAlertProvider.URL != "http://example.com" {
t.Error("expected URL to be http://example.com, got", customAlertProvider.URL)
}
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
const (
ExpectedURL = "http://example.com/service-name?event=test&description=alert-description"
ExpectedBody = "service-name,alert-description,test"
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,test"
)
customAlertProvider := &AlertProvider{
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
@@ -85,7 +154,7 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
},
},
}
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
@@ -97,8 +166,8 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: nil,
}
@@ -109,3 +178,35 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports
// service placeholders after the migration from "service" to "endpoint"
//
// XXX: Remove this in v4.0.0
func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
body, _ := ioutil.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
}
}

View File

@@ -1,19 +1,21 @@
package discord
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -22,15 +24,34 @@ 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 *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
var colorCode int
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
colorCode = 3066993
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
colorCode = 15158332
}
for _, conditionResult := range result.ConditionResults {
@@ -46,10 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
return fmt.Sprintf(`{
"content": "",
"embeds": [
{
@@ -65,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, description, colorCode, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}`, message, description, colorCode, results)
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -3,11 +3,12 @@ package discord
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"}
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if customAlertProvider.URL != "http://example.com" {
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] {
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"])
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"color\": 15158332,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"color\": 3066993,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
},
}
if !strings.Contains(customAlertProvider.Body, "triggered") {
t.Error("customAlertProvider.Body should've contained the substring triggered")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
if customAlertProvider.URL != "http://example.com" {
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,71 @@
package email
import (
"fmt"
"math"
"strings"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
gomail "gopkg.in/mail.v2"
)
// AlertProvider is the configuration necessary for sending an alert using SMTP
type AlertProvider struct {
From string `yaml:"from"`
Password string `yaml:"password"`
Host string `yaml:"host"`
Port int `yaml:"port"`
To string `yaml:"to"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
m := gomail.NewMessage()
m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.To, ",")...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
d := gomail.NewDialer(provider.Host, provider.Port, provider.From, provider.Password)
return d.DialAndSend(m)
}
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
var subject, message, results string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.Name)
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.Name)
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + "\n\nCondition results:\n" + results
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,79 @@
package email
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedSubject string
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if subject != scenario.ExpectedSubject {
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
}
if body != scenario.ExpectedBody {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,13 +1,14 @@
package mattermost
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/client"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Mattermost
@@ -17,7 +18,7 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -29,15 +30,33 @@ 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 *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var color string
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
color = "#DD0000"
}
var results string
@@ -54,14 +73,10 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
ClientConfig: provider.ClientConfig,
Body: fmt.Sprintf(`{
return fmt.Sprintf(`{
"text": "",
"username": "gatus",
"icon_url": "https://raw.githubusercontent.com/TwinProduction/gatus/master/static/logo.png",
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
"attachments": [
{
"title": ":rescue_worker_helmet: Gatus",
@@ -83,9 +98,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, message, description, color, service.URL, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}`, message, message, description, color, endpoint.URL, results)
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -3,11 +3,12 @@ package mattermost
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"}
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if customAlertProvider.URL != "http://example.org" {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
}
if !strings.Contains(customAlertProvider.Body, "triggered") {
t.Error("customAlertProvider.Body should've contained the substring triggered")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
if customAlertProvider.URL != "http://example.org" {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,12 +1,14 @@
package messagebird
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
const (
@@ -19,8 +21,8 @@ type AlertProvider struct {
Originator string `yaml:"originator"`
Recipients string `yaml:"recipients"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
@@ -28,29 +30,40 @@ 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 *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
// Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
return &custom.AlertProvider{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
}
return 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),
},
}
}`, provider.Originator, provider.Recipients, message)
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -3,11 +3,12 @@ package messagebird
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
@@ -25,54 +26,137 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}",
},
{
Name: "resolved",
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}",
},
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
}
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,12 +1,16 @@
package pagerduty
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
const (
@@ -17,33 +21,84 @@ const (
type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
IntegrationKey string `yaml:"integration-key"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.IntegrationKey) == 32
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
return false
}
registeredGroups[override.Group] = true
}
}
// Either the default integration key has the right length, or there are overrides who are properly configured.
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
// Send an alert using the provider
//
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
if alert.IsSendingOnResolved() {
if resolved {
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
// We need to retrieve the resolve key from the response
body, err := ioutil.ReadAll(response.Body)
var payload pagerDutyResponsePayload
if err = json.Unmarshal(body, &payload); err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
} else {
alert.ResolveKey = payload.DedupKey
}
}
}
return nil
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, eventAction, resolveKey string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
eventAction = "resolve"
resolveKey = alert.ResolveKey
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
eventAction = "trigger"
resolveKey = ""
}
return &custom.AlertProvider{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
return fmt.Sprintf(`{
"routing_key": "%s",
"dedup_key": "%s",
"event_action": "%s",
@@ -52,14 +107,28 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
"source": "%s",
"severity": "critical"
}
}`, provider.IntegrationKey, resolveKey, eventAction, message, service.Name),
Headers: map[string]string{
"Content-Type": "application/json",
},
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
}
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.IntegrationKey
}
}
}
return provider.IntegrationKey
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
type pagerDutyResponsePayload struct {
Status string `json:"status"`
Message string `json:"message"`
DedupKey string `json:"dedup_key"`
}

View File

@@ -3,11 +3,12 @@ package pagerduty
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -21,46 +22,225 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "",
},
},
}
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
providerWithInvalidOverrideIntegrationKey := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "",
Group: "group",
},
},
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
if providerWithInvalidOverrideIntegrationKey.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
providerWithValidOverride := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
})
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
description := "test"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description},
Resolved: false,
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"\",\n \"event_action\": \"trigger\",\n \"payload\": {\n \"summary\": \"TRIGGERED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
},
{
Name: "resolved",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true,
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"key\",\n \"event_action\": \"resolve\",\n \"payload\": {\n \"summary\": \"RESOLVED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000002",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,17 +1,18 @@
package provider
import (
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/alerting/provider/discord"
"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/teams"
"github.com/TwinProduction/gatus/alerting/provider/telegram"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the interface that each providers should implement
@@ -19,32 +20,32 @@ 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 *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
// GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert
// Send an alert using the provider
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
}
// ParseWithDefaultAlert parses a service alert by using the provider's default alert as a baseline
func ParseWithDefaultAlert(providerDefaultAlert, serviceAlert *alert.Alert) {
if providerDefaultAlert == nil || serviceAlert == nil {
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
if providerDefaultAlert == nil || endpointAlert == nil {
return
}
if serviceAlert.Enabled == nil {
serviceAlert.Enabled = providerDefaultAlert.Enabled
if endpointAlert.Enabled == nil {
endpointAlert.Enabled = providerDefaultAlert.Enabled
}
if serviceAlert.SendOnResolved == nil {
serviceAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
if endpointAlert.SendOnResolved == nil {
endpointAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
}
if serviceAlert.Description == nil {
serviceAlert.Description = providerDefaultAlert.Description
if endpointAlert.Description == nil {
endpointAlert.Description = providerDefaultAlert.Description
}
if serviceAlert.FailureThreshold == 0 {
serviceAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
if endpointAlert.FailureThreshold == 0 {
endpointAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
}
if serviceAlert.SuccessThreshold == 0 {
serviceAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
if endpointAlert.SuccessThreshold == 0 {
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
}
}
@@ -52,6 +53,7 @@ var (
// Validate interface implementation on compile
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)

View File

@@ -3,13 +3,13 @@ package provider
import (
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/alert"
)
func TestParseWithDefaultAlert(t *testing.T) {
type Scenario struct {
Name string
DefaultAlert, ServiceAlert, ExpectedOutputAlert *alert.Alert
DefaultAlert, EndpointAlert, ExpectedOutputAlert *alert.Alert
}
enabled := true
disabled := false
@@ -17,7 +17,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
secondDescription := "description-2"
scenarios := []Scenario{
{
Name: "service-alert-type-only",
Name: "endpoint-alert-type-only",
DefaultAlert: &alert.Alert{
Enabled: &enabled,
SendOnResolved: &enabled,
@@ -25,7 +25,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
FailureThreshold: 5,
SuccessThreshold: 10,
},
ServiceAlert: &alert.Alert{
EndpointAlert: &alert.Alert{
Type: alert.TypeDiscord,
},
ExpectedOutputAlert: &alert.Alert{
@@ -38,7 +38,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
},
},
{
Name: "service-alert-overwrites-default-alert",
Name: "endpoint-alert-overwrites-default-alert",
DefaultAlert: &alert.Alert{
Enabled: &disabled,
SendOnResolved: &disabled,
@@ -46,7 +46,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
FailureThreshold: 5,
SuccessThreshold: 10,
},
ServiceAlert: &alert.Alert{
EndpointAlert: &alert.Alert{
Type: alert.TypeTelegram,
Enabled: &enabled,
SendOnResolved: &enabled,
@@ -64,7 +64,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
},
},
{
Name: "service-alert-partially-overwrites-default-alert",
Name: "endpoint-alert-partially-overwrites-default-alert",
DefaultAlert: &alert.Alert{
Enabled: &enabled,
SendOnResolved: &enabled,
@@ -72,7 +72,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
FailureThreshold: 5,
SuccessThreshold: 10,
},
ServiceAlert: &alert.Alert{
EndpointAlert: &alert.Alert{
Type: alert.TypeDiscord,
Enabled: nil,
SendOnResolved: nil,
@@ -98,7 +98,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
FailureThreshold: 5,
SuccessThreshold: 10,
},
ServiceAlert: &alert.Alert{
EndpointAlert: &alert.Alert{
Type: alert.TypeDiscord,
},
ExpectedOutputAlert: &alert.Alert{
@@ -120,33 +120,33 @@ func TestParseWithDefaultAlert(t *testing.T) {
FailureThreshold: 2,
SuccessThreshold: 5,
},
ServiceAlert: nil,
EndpointAlert: nil,
ExpectedOutputAlert: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.ServiceAlert)
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil {
if scenario.ServiceAlert != nil {
if scenario.EndpointAlert != nil {
t.Fail()
}
return
}
if scenario.ServiceAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
t.Errorf("expected ServiceAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.ServiceAlert.IsEnabled())
if scenario.EndpointAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
t.Errorf("expected EndpointAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.EndpointAlert.IsEnabled())
}
if scenario.ServiceAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
t.Errorf("expected ServiceAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.ServiceAlert.IsSendingOnResolved())
if scenario.EndpointAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
t.Errorf("expected EndpointAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.EndpointAlert.IsSendingOnResolved())
}
if scenario.ServiceAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
t.Errorf("expected ServiceAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.ServiceAlert.GetDescription())
if scenario.EndpointAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
t.Errorf("expected EndpointAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.EndpointAlert.GetDescription())
}
if scenario.ServiceAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
t.Errorf("expected ServiceAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.ServiceAlert.FailureThreshold)
if scenario.EndpointAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
t.Errorf("expected EndpointAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.EndpointAlert.FailureThreshold)
}
if scenario.ServiceAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
t.Errorf("expected ServiceAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.ServiceAlert.SuccessThreshold)
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
}
})
}

View File

@@ -1,19 +1,21 @@
package slack
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -22,14 +24,33 @@ 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 *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color, results string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
color = "#DD0000"
}
for _, conditionResult := range result.ConditionResults {
@@ -45,10 +66,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
return fmt.Sprintf(`{
"text": "",
"attachments": [
{
@@ -65,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, description, color, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}`, message, description, color, results)
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -3,11 +3,12 @@ package slack
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"}
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if customAlertProvider.URL != "http://example.com" {
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
}
if !strings.Contains(customAlertProvider.Body, "triggered") {
t.Error("customAlertProvider.Body should've contained the substring triggered")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
if customAlertProvider.URL != "http://example.com" {
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,19 +1,21 @@
package teams
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -22,15 +24,33 @@ 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 *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var color string
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
color = "#DD0000"
}
var results string
@@ -47,10 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
return fmt.Sprintf(`{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "%s",
@@ -66,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
"text": "%s"
}
]
}`, color, message, description, service.URL, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}`, color, message, description, endpoint.URL, results)
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -3,11 +3,12 @@ package teams
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"}
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if customAlertProvider.URL != "http://example.org" {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] {
t.Errorf("expected $.text to be %s, got %s", expected, body["text"])
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#DD0000\",\n \"title\": \"&#x1F6A8; Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"&#x274C; - `[CONNECTED] == true`<br/>&#x274C; - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#36A64F\",\n \"title\": \"&#x1F6A8; Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"&#x2705; - `[CONNECTED] == true`<br/>&#x2705; - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
},
}
if !strings.Contains(customAlertProvider.Body, "triggered") {
t.Error("customAlertProvider.Body should've contained the substring triggered")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
if customAlertProvider.URL != "http://example.org" {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,12 +1,14 @@
package telegram
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Telegram
@@ -14,7 +16,7 @@ type AlertProvider struct {
Token string `yaml:"token"`
ID string `yaml:"id"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -23,13 +25,32 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.Token) > 0 && len(provider.ID) > 0
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold)
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
@@ -46,12 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
} else {
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
}
return &custom.AlertProvider{
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token),
Method: http.MethodPost,
Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text),
Headers: map[string]string{"Content-Type": "application/json"},
}
return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -2,13 +2,13 @@ package telegram
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -22,70 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
//_, err := json.Marshal(customAlertProvider.Body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
description := "Healthcheck Successful"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
},
{
Name: "resolved",
Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
},
}
if !strings.Contains(customAlertProvider.Body, "triggered") {
t.Error("customAlertProvider.Body should've contained the substring triggered")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if !strings.Contains(customAlertProvider.Body, "triggered") {
t.Error("customAlertProvider.Body should've contained the substring triggered")
}
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,14 +1,16 @@
package twilio
import (
"bytes"
"encoding/base64"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -18,7 +20,7 @@ type AlertProvider struct {
From string `yaml:"from"`
To string `yaml:"to"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -27,27 +29,39 @@ 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 *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := ioutil.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
}
return &custom.AlertProvider{
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
Method: http.MethodPost,
Body: url.Values{
return url.Values{
"To": {provider.To},
"From": {provider.From},
"Body": {message},
}.Encode(),
Headers: map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", provider.SID, provider.Token)))),
},
}
}.Encode()
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -1,12 +1,10 @@
package twilio
import (
"net/http"
"strings"
"testing"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {
@@ -25,54 +23,56 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{
SID: "1",
Token: "2",
From: "3",
To: "4",
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "resolved",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
},
}
description := "alert-description"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
}
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" {
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
if customAlertProvider.Body != "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4" {
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
})
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{
SID: "4",
Token: "3",
From: "2",
To: "1",
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
description := "alert-description"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
}
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" {
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
if customAlertProvider.Body != "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1" {
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -14,15 +14,21 @@ import (
"github.com/go-ping/ping"
)
// injectedHTTPClient is used for testing purposes
var injectedHTTPClient *http.Client
// GetHTTPClient returns the shared HTTP client
func GetHTTPClient(config *Config) *http.Client {
if injectedHTTPClient != nil {
return injectedHTTPClient
}
if config == nil {
return defaultConfig.getHTTPClient()
}
return config.getHTTPClient()
}
// CanCreateTCPConnection checks whether a connection can be established with a TCP service
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
@@ -38,7 +44,11 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
if len(hostAndPort) != 2 {
return false, nil, errors.New("invalid address for starttls, format must be host:port")
}
smtpClient, err := smtp.Dial(address)
connection, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return
}
smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
if err != nil {
return
}
@@ -57,6 +67,20 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
return true, certificate, nil
}
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, nil)
if err != nil {
return
}
defer connection.Close()
verifiedChains := connection.ConnectionState().VerifiedChains
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
return
}
return true, verifiedChains[0][0], nil
}
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
//
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
@@ -67,8 +91,11 @@ func Ping(address string, config *Config) (bool, time.Duration) {
}
pinger.Count = 1
pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for every operating system except darwin
// https://github.com/TwinProduction/gatus/issues/132
// Set the pinger's privileged mode to true for every GOOS except darwin
// See https://github.com/TwiN/gatus/issues/132
//
// Note that for this to work on Linux, Gatus must run with sudo privileges.
// See https://github.com/go-ping/ping#linux
pinger.SetPrivileged(runtime.GOOS != "darwin")
err = pinger.Run()
if err != nil {
@@ -83,3 +110,8 @@ func Ping(address string, config *Config) (bool, time.Duration) {
}
return true, 0
}
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
func InjectHTTPClient(httpClient *http.Client) {
injectedHTTPClient = httpClient
}

View File

@@ -91,6 +91,56 @@ func TestCanPerformStartTLS(t *testing.T) {
}
}
func TestCanPerformTLS(t *testing.T) {
type args struct {
address string
insecure bool
}
tests := []struct {
name string
args args
wantConnected bool
wantErr bool
}{
{
name: "invalid address",
args: args{
address: "test",
},
wantConnected: false,
wantErr: true,
},
{
name: "error dial",
args: args{
address: "test:1234",
},
wantConnected: false,
wantErr: true,
},
{
name: "valid tls",
args: args{
address: "smtp.gmail.com:465",
},
wantConnected: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
}
})
}
}
func TestCanCreateTCPConnection(t *testing.T) {
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
t.Error("should've failed, because there's no port in the address")

View File

@@ -46,7 +46,7 @@ func (c *Config) ValidateAndSetDefaults() {
}
}
// GetHTTPClient return a HTTP client matching the Config's parameters.
// GetHTTPClient return an HTTP client matching the Config's parameters.
func (c *Config) getHTTPClient() *http.Client {
if c.httpClient == nil {
c.httpClient = &http.Client{

View File

@@ -1,8 +1,8 @@
services:
endpoints:
- name: front-end
group: core
url: "https://twinnation.org/health"
interval: 1m
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"

View File

@@ -7,12 +7,15 @@ import (
"os"
"time"
"github.com/TwinProduction/gatus/alerting"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/storage"
"github.com/TwiN/gatus/v3/alerting"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/config/maintenance"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v3/storage"
"gopkg.in/yaml.v2"
)
@@ -24,17 +27,11 @@ const (
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
// configuration file if DefaultConfigurationFilePath didn't work
DefaultFallbackConfigurationFilePath = "config/config.yml"
// DefaultAddress is the default address the service will bind to
DefaultAddress = "0.0.0.0"
// DefaultPort is the default port the service will listen on
DefaultPort = 8080
)
var (
// ErrNoServiceInConfig is an error returned when a configuration file has no services configured
ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service")
// ErrNoEndpointInConfig is an error returned when a configuration file has no endpoints configured
ErrNoEndpointInConfig = errors.New("configuration file should contain at least 1 endpoint")
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
ErrConfigFileNotFound = errors.New("configuration file not found")
@@ -46,34 +43,48 @@ var (
// Config is the main configuration structure
type Config struct {
// Debug Whether to enable debug logs
Debug bool `yaml:"debug"`
Debug bool `yaml:"debug,omitempty"`
// Metrics Whether to expose metrics at /metrics
Metrics bool `yaml:"metrics"`
Metrics bool `yaml:"metrics,omitempty"`
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
// if the configuration file is updated while the application is running
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update"`
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update,omitempty"`
// DisableMonitoringLock Whether to disable the monitoring lock
// The monitoring lock is what prevents multiple services from being processed at the same time.
// The monitoring lock is what prevents multiple endpoints from being processed at the same time.
// Disabling this may lead to inaccurate response times
DisableMonitoringLock bool `yaml:"disable-monitoring-lock"`
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
// Security Configuration for securing access to Gatus
Security *security.Config `yaml:"security"`
Security *security.Config `yaml:"security,omitempty"`
// Alerting Configuration for alerting
Alerting *alerting.Config `yaml:"alerting"`
Alerting *alerting.Config `yaml:"alerting,omitempty"`
// Services List of services to monitor
Services []*core.Service `yaml:"services"`
// Endpoints List of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
// Services List of endpoints to monitor
//
// XXX: Remove this in v5.0.0
// XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate
//
// Deprecated in favor of Endpoints
Services []*core.Endpoint `yaml:"services,omitempty"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage"`
Storage *storage.Config `yaml:"storage,omitempty"`
// Web is the configuration for the web listener
Web *WebConfig `yaml:"web"`
// Web is the web configuration for the application
Web *web.Config `yaml:"web,omitempty"`
// UI is the configuration for the UI
UI *ui.Config `yaml:"ui,omitempty"`
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
filePath string // path to the file from which config was loaded from
lastFileModTime time.Time // last modification time
@@ -138,30 +149,40 @@ func readConfigurationFile(fileName string) (config *Config, err error) {
return
}
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
// Expand environment variables
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
// Parse configuration file
err = yaml.Unmarshal(yamlBytes, &config)
if err != nil {
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
return
}
// Check if the configuration file at least has services configured
if config == nil || config.Services == nil || len(config.Services) == 0 {
err = ErrNoServiceInConfig
if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0
log.Println("WARNING: Your configuration is using 'services:', which is deprecated in favor of 'endpoints:'.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/191 for more information")
config.Endpoints = append(config.Endpoints, config.Services...)
config.Services = nil
}
// Check if the configuration file at least has endpoints configured
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig
} else {
// Note that the functions below may panic, and this is on purpose to prevent Gatus from starting with
// invalid configurations
validateAlertingConfig(config.Alerting, config.Services, config.Debug)
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug)
if err := validateSecurityConfig(config); err != nil {
return nil, err
}
if err := validateServicesConfig(config); err != nil {
if err := validateEndpointsConfig(config); err != nil {
return nil, err
}
if err := validateWebConfig(config); err != nil {
return nil, err
}
if err := validateUIConfig(config); err != nil {
return nil, err
}
if err := validateMaintenanceConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil {
return nil, err
}
@@ -174,42 +195,55 @@ func validateStorageConfig(config *Config) error {
config.Storage = &storage.Config{
Type: storage.TypeMemory,
}
}
err := storage.Initialize(config.Storage)
if err != nil {
} else {
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
return err
}
// Remove all ServiceStatus that represent services which no longer exist in the configuration
var keys []string
for _, service := range config.Services {
keys = append(keys, service.Key())
}
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
if numberOfServiceStatusesDeleted > 0 {
log.Printf("[config][validateStorageConfig] Deleted %d service statuses because their matching services no longer existed", numberOfServiceStatusesDeleted)
return nil
}
func validateMaintenanceConfig(config *Config) error {
if config.Maintenance == nil {
config.Maintenance = maintenance.GetDefaultConfig()
} else {
if err := config.Maintenance.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
} else {
if err := config.UI.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
config.Web = web.GetDefaultConfig()
} else {
return config.Web.validateAndSetDefaults()
return config.Web.ValidateAndSetDefaults()
}
return nil
}
func validateServicesConfig(config *Config) error {
for _, service := range config.Services {
func validateEndpointsConfig(config *Config) error {
for _, endpoint := range config.Endpoints {
if config.Debug {
log.Printf("[config][validateServicesConfig] Validating service '%s'", service.Name)
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
}
if err := service.ValidateAndSetDefaults(); err != nil {
if err := endpoint.ValidateAndSetDefaults(); err != nil {
return err
}
}
log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services))
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
return nil
}
@@ -229,10 +263,10 @@ func validateSecurityConfig(config *Config) error {
}
// validateAlertingConfig validates the alerting configuration
// Note that the alerting configuration has to be validated before the service configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Service.ValidateAndSetDefaults()
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Endpoint.ValidateAndSetDefaults()
// sets the default alert values when none are set.
func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Service, debug bool) {
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) {
if alertingConfig == nil {
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
return
@@ -240,6 +274,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
alertTypes := []alert.Type{
alert.TypeCustom,
alert.TypeDiscord,
alert.TypeEmail,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypePagerDuty,
@@ -255,13 +290,13 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
if alertProvider.IsValid() {
// Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil {
for _, service := range services {
for alertIndex, serviceAlert := range service.Alerts {
if alertType == serviceAlert.Type {
for _, endpoint := range endpoints {
for alertIndex, endpointAlert := range endpoint.Alerts {
if alertType == endpointAlert.Type {
if debug {
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in service=%s", alertIndex, alertType, service.Name)
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
}
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), serviceAlert)
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
package maintenance
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
errInvalidMaintenanceStartFormat = errors.New("invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)")
errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 (e.g. 30m)")
errInvalidDayName = fmt.Errorf("invalid value specified for 'on'. supported values are %s", longDayNames)
longDayNames = []string{
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
}
)
// Config allows for the configuration of a maintenance period.
// During this maintenance period, no alerts will be sent.
//
// Uses UTC.
type Config struct {
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
// Every is a list of days of the week during which maintenance period applies.
// See longDayNames for list of valid values.
// Every day if empty.
Every []string `yaml:"every"`
durationToStartFromMidnight time.Duration
}
func GetDefaultConfig() *Config {
defaultValue := false
return &Config{
Enabled: &defaultValue,
}
}
// IsEnabled returns whether maintenance is enabled or not
func (c Config) IsEnabled() bool {
if c.Enabled == nil {
return true
}
return *c.Enabled
}
// ValidateAndSetDefaults validates the maintenance configuration and sets the default values if necessary.
//
// Must be called once in the application's lifecycle before IsUnderMaintenance is called, since it
// also sets durationToStartFromMidnight.
func (c *Config) ValidateAndSetDefaults() error {
if c == nil || !c.IsEnabled() {
// Don't waste time validating if maintenance is not enabled.
return nil
}
for _, day := range c.Every {
isDayValid := false
for _, longDayName := range longDayNames {
if day == longDayName {
isDayValid = true
break
}
}
if !isDayValid {
return errInvalidDayName
}
}
var err error
c.durationToStartFromMidnight, err = hhmmToDuration(c.Start)
if err != nil {
return err
}
if c.Duration <= 0 || c.Duration >= 24*time.Hour {
return errInvalidMaintenanceDuration
}
return nil
}
// IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window
func (c Config) IsUnderMaintenance() bool {
if !c.IsEnabled() {
return false
}
now := time.Now().UTC()
var dayWhereMaintenancePeriodWouldStart time.Time
if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) {
dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour)
} else {
dayWhereMaintenancePeriodWouldStart = now.Add(-c.Duration).Truncate(24 * time.Hour)
}
hasMaintenanceEveryDay := len(c.Every) == 0
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
// The day when the maintenance period would start is not scheduled
// to have any maintenance, so we can just return false.
return false
}
startOfMaintenancePeriod := dayWhereMaintenancePeriodWouldStart.Add(c.durationToStartFromMidnight)
endOfMaintenancePeriod := startOfMaintenancePeriod.Add(c.Duration)
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
}
func (c Config) hasDay(day string) bool {
for _, d := range c.Every {
if d == day {
return true
}
}
return false
}
func hhmmToDuration(s string) (time.Duration, error) {
if len(s) != 5 {
return 0, errInvalidMaintenanceStartFormat
}
var hours, minutes int
var err error
if hours, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[:2]); err != nil {
return 0, err
}
if minutes, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[3:5]); err != nil {
return 0, err
}
duration := (time.Duration(hours) * time.Hour) + (time.Duration(minutes) * time.Minute)
if hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || duration < 0 || duration >= 24*time.Hour {
return 0, errInvalidMaintenanceStartFormat
}
return duration, nil
}
func extractNumericalValueFromPotentiallyZeroPaddedString(s string) (int, error) {
return strconv.Atoi(strings.TrimPrefix(s, "0"))
}

View File

@@ -0,0 +1,226 @@
package maintenance
import (
"errors"
"fmt"
"strconv"
"testing"
"time"
)
func TestGetDefaultConfig(t *testing.T) {
if *GetDefaultConfig().Enabled {
t.Fatal("expected default config to be disabled by default")
}
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
yes, no := true, false
scenarios := []struct {
name string
cfg *Config
expectedError error
}{
{
name: "nil",
cfg: nil,
expectedError: nil,
},
{
name: "disabled",
cfg: &Config{
Enabled: &no,
},
expectedError: nil,
},
{
name: "invalid-day",
cfg: &Config{
Every: []string{"invalid-day"},
},
expectedError: errInvalidDayName,
},
{
name: "invalid-day",
cfg: &Config{
Every: []string{"invalid-day"},
},
expectedError: errInvalidDayName,
},
{
name: "invalid-start-format",
cfg: &Config{
Start: "0000",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-hours",
cfg: &Config{
Start: "25:00",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-minutes",
cfg: &Config{
Start: "0:61",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-minutes-non-numerical",
cfg: &Config{
Start: "00:zz",
},
expectedError: strconv.ErrSyntax,
},
{
name: "invalid-start-hours-non-numerical",
cfg: &Config{
Start: "zz:00",
},
expectedError: strconv.ErrSyntax,
},
{
name: "invalid-duration",
cfg: &Config{
Start: "23:00",
Duration: 0,
},
expectedError: errInvalidMaintenanceDuration,
},
{
name: "every-day-at-2300",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
},
expectedError: nil,
},
{
name: "every-monday-at-0000",
cfg: &Config{
Start: "00:00",
Duration: 30 * time.Minute,
Every: []string{"Monday"},
},
expectedError: nil,
},
{
name: "every-friday-and-sunday-at-0000-explicitly-enabled",
cfg: &Config{
Enabled: &yes,
Start: "08:00",
Duration: 8 * time.Hour,
Every: []string{"Friday", "Sunday"},
},
expectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected %v, got %v", scenario.expectedError, err)
}
})
}
}
func TestConfig_IsUnderMaintenance(t *testing.T) {
yes, no := true, false
now := time.Now().UTC()
scenarios := []struct {
name string
cfg *Config
expected bool
}{
{
name: "disabled",
cfg: &Config{
Enabled: &no,
},
expected: false,
},
{
name: "under-maintenance-explicitly-enabled",
cfg: &Config{
Enabled: &yes,
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-2h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-8h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 8 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-8h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
Duration: 8 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
Duration: 3 * time.Hour,
},
expected: false,
},
{
name: "under-maintenance-starting-5h-ago-for-1h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
Duration: time.Hour,
},
expected: false,
},
{
name: "not-under-maintenance-today",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: time.Hour,
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
},
expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
t.Log(scenario.cfg.Start)
t.Log(now)
if err := scenario.cfg.ValidateAndSetDefaults(); err != nil {
t.Fatal("validation shouldn't have returned an error, got", err)
}
isUnderMaintenance := scenario.cfg.IsUnderMaintenance()
if isUnderMaintenance != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, isUnderMaintenance)
t.Logf("start=%v; duration=%v; now=%v", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())
}
})
}
}
func normalizeHour(hour int) int {
if hour < 0 {
return hour + 24
}
return hour
}

48
config/ui/ui.go Normal file
View File

@@ -0,0 +1,48 @@
package ui
import (
"bytes"
"html/template"
)
const (
defaultTitle = "Health Dashboard | Gatus"
defaultLogo = ""
)
var (
// StaticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
StaticFolder = "./web/static"
)
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title"` // Title of the page
Logo string `yaml:"logo"` // Logo to display on the page
}
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Logo: defaultLogo,
}
}
// ValidateAndSetDefaults validates the UI configuration and sets the default values if necessary.
func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Title) == 0 {
cfg.Title = defaultTitle
}
t, err := template.ParseFiles(StaticFolder + "/index.html")
if err != nil {
return err
}
var buffer bytes.Buffer
err = t.Execute(&buffer, cfg)
if err != nil {
return err
}
return nil
}

26
config/ui/ui_test.go Normal file
View File

@@ -0,0 +1,26 @@
package ui
import (
"testing"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
StaticFolder = "../../web/static"
defer func() {
StaticFolder = "./web/static"
}()
cfg := &Config{Title: ""}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
}
func TestGetDefaultConfig(t *testing.T) {
defaultConfig := GetDefaultConfig()
if defaultConfig.Title != defaultTitle {
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
}
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
}

View File

@@ -1,13 +1,21 @@
package config
package web
import (
"fmt"
"math"
)
// WebConfig is the structure which supports the configuration of the endpoint
const (
// DefaultAddress is the default address the application will bind to
DefaultAddress = "0.0.0.0"
// DefaultPort is the default port the application will listen on
DefaultPort = 8080
)
// Config is the structure which supports the configuration of the endpoint
// which provides access to the web frontend
type WebConfig struct {
type Config struct {
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
Address string `yaml:"address"`
@@ -15,8 +23,13 @@ type WebConfig struct {
Port int `yaml:"port"`
}
// validateAndSetDefaults checks and sets the default values for fields that are not set
func (web *WebConfig) validateAndSetDefaults() error {
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{Address: DefaultAddress, Port: DefaultPort}
}
// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.
func (web *Config) ValidateAndSetDefaults() error {
// Validate the Address
if len(web.Address) == 0 {
web.Address = DefaultAddress
@@ -31,6 +44,6 @@ func (web *WebConfig) validateAndSetDefaults() error {
}
// SocketAddress returns the combination of the Address and the Port
func (web *WebConfig) SocketAddress() string {
func (web *Config) SocketAddress() string {
return fmt.Sprintf("%s:%d", web.Address, web.Port)
}

65
config/web/web_test.go Normal file
View File

@@ -0,0 +1,65 @@
package web
import (
"testing"
)
func TestGetDefaultConfig(t *testing.T) {
defaultConfig := GetDefaultConfig()
if defaultConfig.Port != DefaultPort {
t.Error("expected default config to have the default port")
}
if defaultConfig.Address != DefaultAddress {
t.Error("expected default config to have the default address")
}
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
scenarios := []struct {
name string
cfg *Config
expectedAddress string
expectedPort int
expectedErr bool
}{
{
name: "no-explicit-config",
cfg: &Config{},
expectedAddress: "0.0.0.0",
expectedPort: 8080,
expectedErr: false,
},
{
name: "invalid-port",
cfg: &Config{Port: 100000000},
expectedErr: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if (err != nil) != scenario.expectedErr {
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
return
}
if !scenario.expectedErr {
if scenario.cfg.Port != scenario.expectedPort {
t.Errorf("expected port to be %d, got %d", scenario.expectedPort, scenario.cfg.Port)
}
if scenario.cfg.Address != scenario.expectedAddress {
t.Errorf("expected address to be %s, got %s", scenario.expectedAddress, scenario.cfg.Address)
}
}
})
}
}
func TestConfig_SocketAddress(t *testing.T) {
web := &Config{
Address: "0.0.0.0",
Port: 8081,
}
if web.SocketAddress() != "0.0.0.0:8081" {
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
}
}

View File

@@ -1,15 +0,0 @@
package config
import (
"testing"
)
func TestWebConfig_SocketAddress(t *testing.T) {
web := &WebConfig{
Address: "0.0.0.0",
Port: 8081,
}
if web.SocketAddress() != "0.0.0.0:8081" {
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
}
}

View File

@@ -1,116 +0,0 @@
package controller
import (
"strconv"
"testing"
)
func TestGetBadgeColorFromUptime(t *testing.T) {
scenarios := []struct {
Uptime float64
ExpectedColor string
}{
{
Uptime: 1,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.99,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.97,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.95,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.93,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.9,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.85,
ExpectedColor: badgeColorHexPassable,
},
{
Uptime: 0.7,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.65,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.6,
ExpectedColor: badgeColorHexVeryBad,
},
}
for _, scenario := range scenarios {
t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {
if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {
t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))
}
})
}
}
func TestGetBadgeColorFromResponseTime(t *testing.T) {
scenarios := []struct {
ResponseTime int
ExpectedColor string
}{
{
ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome,
},
{
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
ResponseTime: 75,
ExpectedColor: badgeColorHexGreat,
},
{
ResponseTime: 150,
ExpectedColor: badgeColorHexGreat,
},
{
ResponseTime: 201,
ExpectedColor: badgeColorHexGood,
},
{
ResponseTime: 300,
ExpectedColor: badgeColorHexGood,
},
{
ResponseTime: 301,
ExpectedColor: badgeColorHexPassable,
},
{
ResponseTime: 450,
ExpectedColor: badgeColorHexPassable,
},
{
ResponseTime: 700,
ExpectedColor: badgeColorHexBad,
},
{
ResponseTime: 1500,
ExpectedColor: badgeColorHexVeryBad,
},
}
for _, scenario := range scenarios {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime))
}
})
}
}

View File

@@ -1,49 +1,30 @@
package controller
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gocache"
"github.com/TwinProduction/health"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const (
cacheTTL = 10 * time.Second
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/controller/handler"
"github.com/TwiN/gatus/v3/security"
)
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
// staticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
staticFolder = "./web/static"
// server is the http.Server created by Handle.
// The only reason it exists is for testing purposes.
server *http.Server
)
// Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics)
if os.Getenv("ENVIRONMENT") == "dev" {
router = developmentCorsHandler(router)
router = handler.DevelopmentCORS(router)
}
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
@@ -66,98 +47,3 @@ func Shutdown() {
server = nil
}
}
// CreateRouter creates the router for the http server
func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Router {
router := mux.NewRouter()
if enabledMetrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
}
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
// New endpoints
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
// TODO: router.HandleFunc("/api/v1/services/{key}/uptimes", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceUptimesHandler))).Methods("GET")
// TODO: router.HandleFunc("/api/v1/services/{key}/events", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceEventsHandler))).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", uptimeBadgeHandler).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", responseTimeBadgeHandler).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", responseTimeChartHandler).Methods("GET")
// SPA
router.HandleFunc("/services/{service}", spaHandler).Methods("GET")
// Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
return router
}
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
if securityConfig != nil && securityConfig.IsValid() {
return security.Handler(handler, securityConfig)
}
return handler
}
// serviceStatusesHandler handles requests to retrieve all service statuses
// Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool
var value interface{}
if gzipped {
writer.Header().Set("Content-Encoding", "gzip")
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize))
} else {
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize))
}
var data []byte
if !exists {
var err error
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
data, err = json.Marshal(storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize)))
if err != nil {
log.Printf("[controller][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()
gzippedData := buffer.Bytes()
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
if gzipped {
data = gzippedData
}
} else {
data = value.([]byte)
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
}
// serviceStatusHandler retrieves a single ServiceStatus by group name and service name
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
vars := mux.Vars(r)
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if serviceStatus == nil {
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("not found"))
return
}
output, err := json.Marshal(serviceStatus)
if err != nil {
log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error())
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("unable to marshal object to JSON"))
return
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(output)
}

View File

@@ -6,274 +6,19 @@ import (
"net/http/httptest"
"os"
"testing"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/watchdog"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testService = core.Service{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func TestCreateRouter(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
staticFolder = "../web/static"
cfg := &config.Config{
Metrics: true,
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter(cfg.Security, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "health",
Path: "/health",
ExpectedCode: http.StatusOK,
},
{
Name: "metrics",
Path: "/metrics",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-1h",
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-24h",
Path: "/api/v1/services/core_backend/uptimes/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-7d",
Path: "/api/v1/services/core_frontend/uptimes/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-with-invalid-duration",
Path: "/api/v1/services/core_backend/uptimes/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-uptime-for-invalid-key",
Path: "/api/v1/services/invalid_key/uptimes/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-response-time-1h",
Path: "/api/v1/services/core_frontend/response-times/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-7d",
Path: "/api/v1/services/core_frontend/response-times/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-with-invalid-duration",
Path: "/api/v1/services/core_backend/response-times/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-response-time-for-invalid-key",
Path: "/api/v1/services/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-7d",
Path: "/api/v1/services/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/services/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "chart-response-time-for-invalid-key",
Path: "/api/v1/services/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "service-statuses",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "service-statuses-gzip",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "service-statuses-pagination",
Path: "/api/v1/services/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status-gzip",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "service-status-pagination",
Path: "/api/v1/services/core_frontend/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status-for-invalid-key",
Path: "/api/v1/services/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-home",
Path: "/",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-assets",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
func TestHandle(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Web: &config.WebConfig{
Web: &web.Config{
Address: "0.0.0.0",
Port: rand.Intn(65534),
},
Services: []*core.Service{
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
@@ -287,9 +32,9 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.Metrics)
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
defer Shutdown()
request, _ := http.NewRequest("GET", "/health", nil)
request, _ := http.NewRequest("GET", "/health", http.NoBody)
responseRecorder := httptest.NewRecorder()
server.Handler.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
@@ -308,71 +53,3 @@ func TestShutdown(t *testing.T) {
t.Error("server should've been shut down")
}
}
func TestServiceStatusesHandler(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
staticFolder = "../web/static"
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
storage.Get().Insert(&testService, firstResult)
storage.Get().Insert(&testService, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter(nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
ExpectedBody string
}
scenarios := []Scenario{
{
Name: "no-pagination",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
},
{
Name: "pagination-first-result",
Path: "/api/v1/services/statuses?page=1&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
},
{
Name: "pagination-second-result",
Path: "/api/v1/services/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
},
{
Name: "pagination-no-results",
Path: "/api/v1/services/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[],"events":[]}]`,
},
{
Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/services/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
output := responseRecorder.Body.String()
if output != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
}
})
}
}

View File

@@ -1,8 +0,0 @@
package controller
import "net/http"
// favIconHandler handles requests for /favicon.ico
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}

View File

@@ -1,4 +1,4 @@
package controller
package handler
import (
"fmt"
@@ -7,8 +7,8 @@ import (
"strings"
"time"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/gorilla/mux"
)
@@ -21,10 +21,10 @@ const (
badgeColorHexVeryBad = "#c7130a"
)
// uptimeBadgeHandler handles the automatic generation of badge based on the group name and service name passed.
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func uptimeBadgeHandler(writer http.ResponseWriter, request *http.Request) {
func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
@@ -34,33 +34,67 @@ func uptimeBadgeHandler(writer http.ResponseWriter, request *http.Request) {
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-time.Hour)
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
}
key := variables["key"]
uptime, err := storage.Get().GetUptimeByKey(key, from, time.Now())
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrServiceNotFound {
writer.WriteHeader(http.StatusNotFound)
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
writer.WriteHeader(http.StatusBadRequest)
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
writer.WriteHeader(http.StatusInternalServerError)
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
_, _ = writer.Write([]byte(err.Error()))
return
}
formattedDate := time.Now().Format(http.TimeFormat)
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Date", formattedDate)
writer.Header().Set("Expires", formattedDate)
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
}
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
}
key := variables["key"]
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
}
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
switch duration {
@@ -127,46 +161,6 @@ func getBadgeColorFromUptime(uptime float64) string {
return badgeColorHexVeryBad
}
// responseTimeBadgeHandler handles the automatic generation of badge based on the group name and service name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func responseTimeBadgeHandler(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-time.Hour)
default:
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
return
}
key := variables["key"]
averageResponseTime, err := storage.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrServiceNotFound {
writer.WriteHeader(http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
writer.WriteHeader(http.StatusBadRequest)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
_, _ = writer.Write([]byte(err.Error()))
return
}
formattedDate := time.Now().Format(http.TimeFormat)
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Date", formattedDate)
writer.Header().Set("Expires", formattedDate)
writer.Header().Set("Content-Type", "image/svg+xml")
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
}
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte {
var labelWidth, valueWidth int
switch duration {

View File

@@ -0,0 +1,231 @@
package handler
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
func TestUptimeBadge(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "badge-uptime-1h",
Path: "/api/v1/endpoints/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-24h",
Path: "/api/v1/endpoints/core_backend/uptimes/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-7d",
Path: "/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/uptimes/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-uptime-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/uptimes/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-response-time-1h",
Path: "/api/v1/endpoints/core_frontend/response-times/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-badge-uptime-1h",
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
func TestGetBadgeColorFromUptime(t *testing.T) {
scenarios := []struct {
Uptime float64
ExpectedColor string
}{
{
Uptime: 1,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.99,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.97,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.95,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.93,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.9,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.85,
ExpectedColor: badgeColorHexPassable,
},
{
Uptime: 0.7,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.65,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.6,
ExpectedColor: badgeColorHexVeryBad,
},
}
for _, scenario := range scenarios {
t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {
if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {
t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))
}
})
}
}
func TestGetBadgeColorFromResponseTime(t *testing.T) {
scenarios := []struct {
ResponseTime int
ExpectedColor string
}{
{
ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome,
},
{
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
ResponseTime: 75,
ExpectedColor: badgeColorHexGreat,
},
{
ResponseTime: 150,
ExpectedColor: badgeColorHexGreat,
},
{
ResponseTime: 201,
ExpectedColor: badgeColorHexGood,
},
{
ResponseTime: 300,
ExpectedColor: badgeColorHexGood,
},
{
ResponseTime: 301,
ExpectedColor: badgeColorHexPassable,
},
{
ResponseTime: 450,
ExpectedColor: badgeColorHexPassable,
},
{
ResponseTime: 700,
ExpectedColor: badgeColorHexBad,
},
{
ResponseTime: 1500,
ExpectedColor: badgeColorHexVeryBad,
},
}
for _, scenario := range scenarios {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime))
}
})
}
}

View File

@@ -1,4 +1,4 @@
package controller
package handler
import (
"log"
@@ -7,8 +7,8 @@ import (
"sort"
"time"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/gorilla/mux"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
@@ -29,7 +29,7 @@ var (
}
)
func responseTimeChartHandler(writer http.ResponseWriter, r *http.Request) {
func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
duration := vars["duration"]
var from time.Time
@@ -39,25 +39,22 @@ func responseTimeChartHandler(writer http.ResponseWriter, r *http.Request) {
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Durations supported: 7d, 24h"))
http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest)
return
}
hourlyAverageResponseTime, err := storage.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
if err != nil {
if err == common.ErrServiceNotFound {
writer.WriteHeader(http.StatusNotFound)
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
writer.WriteHeader(http.StatusBadRequest)
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
writer.WriteHeader(http.StatusInternalServerError)
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
_, _ = writer.Write([]byte(err.Error()))
return
}
if len(hourlyAverageResponseTime) == 0 {
writer.WriteHeader(http.StatusNoContent)
_, _ = writer.Write(nil)
http.Error(writer, "", http.StatusNoContent)
return
}
series := chart.TimeSeries{
@@ -115,8 +112,11 @@ func responseTimeChartHandler(writer http.ResponseWriter, r *http.Request) {
Series: []chart.Series{series},
}
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
if err := graph.Render(chart.SVG, writer); err != nil {
log.Println("[controller][responseTimeChartHandler] Failed to render response time chart:", err.Error())
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
return
}
}

View File

@@ -0,0 +1,80 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
func TestResponseTimeChart(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "chart-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

View File

@@ -1,8 +1,8 @@
package controller
package handler
import "net/http"
func developmentCorsHandler(next http.Handler) http.Handler {
func DevelopmentCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
next.ServeHTTP(w, r)

View File

@@ -0,0 +1,103 @@
package handler
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gocache"
"github.com/gorilla/mux"
)
const (
cacheTTL = 10 * time.Second
)
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
)
// EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler
func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool
var value interface{}
if gzipped {
writer.Header().Set("Content-Encoding", "gzip")
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize))
} else {
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
}
var data []byte
if !exists {
var err error
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
data, err = json.Marshal(endpointStatuses)
if err != nil {
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
return
}
_, _ = gzipWriter.Write(data)
_ = gzipWriter.Close()
gzippedData := buffer.Bytes()
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
if gzipped {
data = gzippedData
}
} else {
data = value.([]byte)
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
}
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
func EndpointStatus(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
vars := mux.Vars(r)
endpointStatus, err := store.Get().GetEndpointStatusByKey(vars["key"], paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
return
}
log.Printf("[handler][EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
if endpointStatus == nil {
log.Printf("[handler][EndpointStatus] Endpoint with key=%s not found", vars["key"])
http.Error(writer, "not found", http.StatusNotFound)
return
}
output, err := json.Marshal(endpointStatus)
if err != nil {
log.Printf("[handler][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
return
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(output)
}

View File

@@ -0,0 +1,226 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testEndpoint = core.Endpoint{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func TestEndpointStatus(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "endpoint-status",
Path: "/api/v1/endpoints/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "endpoint-status-gzip",
Path: "/api/v1/endpoints/core_frontend/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "endpoint-status-pagination",
Path: "/api/v1/endpoints/core_frontend/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "endpoint-status-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
func TestEndpointStatuses(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
store.Get().Insert(&testEndpoint, firstResult)
store.Get().Insert(&testEndpoint, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", nil, nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
ExpectedBody string
}
scenarios := []Scenario{
{
Name: "no-pagination",
Path: "/api/v1/endpoints/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{
Name: "pagination-first-result",
Path: "/api/v1/endpoints/statuses?page=1&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{
Name: "pagination-second-result",
Path: "/api/v1/endpoints/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{
Name: "pagination-no-results",
Path: "/api/v1/endpoints/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[]}]`,
},
{
Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
output := responseRecorder.Body.String()
if output != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
}
})
}
}

View File

@@ -0,0 +1,12 @@
package handler
import (
"net/http"
)
// FavIcon handles requests for /favicon.ico
func FavIcon(staticFolder string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestFavIcon(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package controller
package handler
import (
"compress/gzip"

View File

@@ -0,0 +1,47 @@
package handler
import (
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/health"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *ui.Config, enabledMetrics bool) *mux.Router {
router := mux.NewRouter()
if enabledMetrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
}
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
// Endpoints
router.HandleFunc("/api/v1/endpoints/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
router.HandleFunc("/api/v1/endpoints/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// XXX: Remove the lines between this and the next XXX comment in v4.0.0
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// XXX: Remove the lines between this and the previous XXX comment in v4.0.0
// SPA
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
// Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
return router
}
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
if securityConfig != nil && securityConfig.IsValid() {
return security.Handler(handler, securityConfig)
}
return handler
}

View File

@@ -0,0 +1,58 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestCreateRouter(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, true)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "health",
Path: "/health",
ExpectedCode: http.StatusOK,
},
{
Name: "metrics",
Path: "/metrics",
ExpectedCode: http.StatusOK,
},
{
Name: "scripts",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
},
{
Name: "scripts-gzipped",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "index-redirect",
Path: "/index.html",
ExpectedCode: http.StatusMovedPermanently,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

27
controller/handler/spa.go Normal file
View File

@@ -0,0 +1,27 @@
package handler
import (
"html/template"
"log"
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
)
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
t, err := template.ParseFiles(staticFolder + "/index.html")
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
return
}
writer.Header().Set("Content-Type", "text/html")
err = t.Execute(writer, ui)
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
return
}
}
}

View File

@@ -0,0 +1,70 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
func TestSinglePageApplication(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "frontend-home",
Path: "/",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-endpoint",
Path: "/endpoints/core_frontend",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

View File

@@ -1,10 +1,10 @@
package controller
package handler
import (
"net/http"
"strconv"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common"
)
const (

View File

@@ -1,4 +1,4 @@
package controller
package handler
import (
"fmt"
@@ -54,7 +54,7 @@ func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), nil)
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
if actualPage != scenario.ExpectedPage {
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)

View File

@@ -1,8 +0,0 @@
package controller
import "net/http"
// spaHandler handles requests for /
func spaHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/index.html")
}

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"github.com/TwinProduction/gatus/jsonpath"
"github.com/TwinProduction/gatus/pattern"
"github.com/TwiN/gatus/v3/jsonpath"
"github.com/TwiN/gatus/v3/pattern"
)
const (
@@ -80,49 +80,49 @@ const (
maximumLengthBeforeTruncatingWhenComparedWithPattern = 25
)
// Condition is a condition that needs to be met in order for a Service to be considered healthy.
// Condition is a condition that needs to be met in order for an Endpoint to be considered healthy.
type Condition string
// evaluate the Condition with the Result of the health check
// TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE)
func (c Condition) evaluate(result *Result) bool {
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 {
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 {
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 {
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 {
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 {
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 {
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
}
} else {
@@ -283,7 +283,7 @@ func prettifyNumericalParameters(parameters []string, resolvedParameters []int64
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
}
// XXX: make this configurable? i.e. show-resolved-conditions-on-failure
// 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.

View File

@@ -6,7 +6,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -15,7 +15,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -24,7 +24,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -33,7 +33,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -42,7 +42,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -51,7 +51,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -60,7 +60,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -69,7 +69,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 200}
condition.evaluate(result)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -78,7 +78,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 400}
condition.evaluate(result)
condition.evaluate(result, false)
}
b.ReportAllocs()
}

View File

@@ -7,14 +7,14 @@ import (
)
func TestCondition_evaluate(t *testing.T) {
type scenario struct {
scenarios := []struct {
Name string
Condition Condition
Result *Result
DontResolveFailedConditions bool
ExpectedSuccess bool
ExpectedOutput string
}
scenarios := []scenario{
}{
{
Name: "ip",
Condition: Condition("[IP] == 127.0.0.1"),
@@ -372,6 +372,14 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (404) == any(200, 429)",
},
{
Name: "status-any-failure-but-dont-resolve",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 404},
DontResolveFailedConditions: true,
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
{
Name: "connected",
Condition: Condition("[CONNECTED] == true"),
@@ -435,6 +443,14 @@ func TestCondition_evaluate(t *testing.T) {
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",
},
{
Name: "no-placeholders",
Condition: Condition("1 == 2"),
@@ -445,7 +461,7 @@ func TestCondition_evaluate(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Condition.evaluate(scenario.Result)
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)
}
@@ -459,7 +475,7 @@ func TestCondition_evaluate(t *testing.T) {
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
condition := Condition("[STATUS] ? 201")
result := &Result{HTTPStatus: 201}
condition.evaluate(result)
condition.evaluate(result, false)
if result.Success {
t.Error("condition was invalid, result should've been a failure")
}

View File

@@ -20,7 +20,7 @@ const (
dnsPort = 53
)
// DNS is the configuration for a Service of type DNS
// DNS is the configuration for a Endpoint of type DNS
type DNS struct {
// QueryType is the type for the DNS records like A, AAAA, CNAME...
QueryType string `yaml:"query-type"`

View File

@@ -3,7 +3,7 @@ package core
import (
"testing"
"github.com/TwinProduction/gatus/pattern"
"github.com/TwiN/gatus/v3/pattern"
)
func TestIntegrationQuery(t *testing.T) {
@@ -103,7 +103,7 @@ func TestIntegrationQuery(t *testing.T) {
}
}
func TestService_ValidateAndSetDefaultsWithNoDNSQueryName(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaultsWithNoDNSQueryName(t *testing.T) {
defer func() { recover() }()
dns := &DNS{
QueryType: "A",
@@ -111,11 +111,11 @@ func TestService_ValidateAndSetDefaultsWithNoDNSQueryName(t *testing.T) {
}
err := dns.validateAndSetDefault()
if err == nil {
t.Fatal("Should've returned an error because service`s dns didn't have a query name, which is a mandatory field for dns")
t.Fatal("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns")
}
}
func TestService_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
defer func() { recover() }()
dns := &DNS{
QueryType: "B",
@@ -123,6 +123,6 @@ func TestService_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
}
err := dns.validateAndSetDefault()
if err == nil {
t.Fatal("Should've returned an error because service`s dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
t.Fatal("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...")
}
}

Some files were not shown because too many files have changed in this diff Show More