Compare commits

...

82 Commits

Author SHA1 Message Date
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
1449 changed files with 391542 additions and 64989 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,14 +3,14 @@ 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
enabled: true
description: "healthcheck failed 3 times in a row"
description: "health check failed 3 times in a row"
send-on-resolved: true
conditions:
- "[STATUS] == 200"

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]

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 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: |

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 TwinProduction
Copyright (c) 2021 TwiN
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

2321
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
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.

View File

@@ -1,17 +1,17 @@
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/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

View File

@@ -9,9 +9,9 @@ import (
"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 +26,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"`
}
@@ -39,7 +39,7 @@ func (provider *AlertProvider) IsValid() bool {
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
return provider
}
@@ -57,7 +57,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 +65,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 +81,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 {
@@ -100,14 +106,14 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
}
// Send a request to the alert provider and return the body
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
func (provider *AlertProvider) Send(endpointName, 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)
request := provider.buildHTTPRequest(endpointName, alertDescription, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return nil, err

View File

@@ -4,8 +4,8 @@ import (
"io/ioutil"
"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 TestAlertProvider_IsValid(t *testing.T) {
@@ -13,7 +13,7 @@ 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")
}
@@ -21,15 +21,15 @@ func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
const (
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
ExpectedBody = "service-name,alert-description,RESOLVED"
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 +41,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())
}
@@ -60,24 +60,24 @@ 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)
provider := AlertProvider{URL: "https://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &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)
if customAlertProvider.URL != "https://example.com" {
t.Error("expected URL to be https://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 +85,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 +97,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 +109,26 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
}
}
// 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

@@ -4,16 +4,16 @@ import (
"fmt"
"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/alerting/provider/custom"
"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"`
}
@@ -23,14 +23,14 @@ func (provider *AlertProvider) IsValid() bool {
}
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
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 {

View File

@@ -6,8 +6,8 @@ import (
"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 TestAlertProvider_IsValid(t *testing.T) {
@@ -24,7 +24,7 @@ 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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{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")
}
@@ -49,7 +49,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}

View File

@@ -4,10 +4,10 @@ import (
"fmt"
"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/alerting/provider/custom"
"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 +17,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"`
}
@@ -30,14 +30,14 @@ func (provider *AlertProvider) IsValid() bool {
}
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var 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
@@ -61,7 +61,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
Body: 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,7 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, message, description, color, service.URL, results),
}`, message, message, description, color, endpoint.URL, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}

View File

@@ -6,8 +6,8 @@ import (
"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 TestAlertProvider_IsValid(t *testing.T) {
@@ -24,7 +24,7 @@ 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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{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")
}
@@ -49,7 +49,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}

View File

@@ -4,9 +4,9 @@ import (
"fmt"
"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/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
const (
@@ -19,7 +19,7 @@ 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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
@@ -30,14 +30,13 @@ func (provider *AlertProvider) IsValid() bool {
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
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: restAPIURL,
Method: http.MethodPost,

View File

@@ -6,8 +6,8 @@ import (
"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 TestMessagebirdAlertProvider_IsValid(t *testing.T) {
@@ -31,7 +31,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
Originator: "1",
Recipients: "1",
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -57,7 +57,7 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
Originator: "1",
Recipients: "1",
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}

View File

@@ -4,9 +4,9 @@ import (
"fmt"
"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/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
const (
@@ -17,26 +17,45 @@ 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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides"`
}
// 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
//
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
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 = ""
}
@@ -52,13 +71,25 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
"source": "%s",
"severity": "critical"
}
}`, provider.IntegrationKey, resolveKey, eventAction, message, service.Name),
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name),
Headers: map[string]string{
"Content-Type": "application/json",
},
}
}
// 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

View File

@@ -6,8 +6,8 @@ import (
"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 TestAlertProvider_IsValid(t *testing.T) {
@@ -21,9 +21,75 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideIntegrationKey := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideIntegrationKey.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
}
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 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_ToCustomAlertProviderWithResolvedAlertAndOverride(t *testing.T) {
provider := AlertProvider{
IntegrationKey: "",
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
},
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -45,7 +111,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -64,3 +130,96 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlertAndOverride(t *testing.T) {
provider := AlertProvider{
IntegrationKey: "",
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
},
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &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://events.pagerduty.com/v2/enqueue" {
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", 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_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)
}
})
}
}

View File

@@ -1,17 +1,17 @@
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/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
@@ -20,31 +20,31 @@ type AlertProvider interface {
IsValid() bool
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
// GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert
}
// 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
}
}

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
Name string
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

@@ -4,16 +4,16 @@ import (
"fmt"
"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/alerting/provider/custom"
"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"`
}
@@ -23,13 +23,13 @@ func (provider *AlertProvider) IsValid() bool {
}
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
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 {

View File

@@ -6,8 +6,8 @@ import (
"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 TestAlertProvider_IsValid(t *testing.T) {
@@ -24,7 +24,7 @@ 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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{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")
}
@@ -49,7 +49,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}

View File

@@ -4,16 +4,16 @@ import (
"fmt"
"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/alerting/provider/custom"
"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"`
}
@@ -23,14 +23,14 @@ func (provider *AlertProvider) IsValid() bool {
}
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var 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
@@ -66,7 +66,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
"text": "%s"
}
]
}`, color, message, description, service.URL, results),
}`, color, message, description, endpoint.URL, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}

View File

@@ -6,8 +6,8 @@ import (
"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 TestAlertProvider_IsValid(t *testing.T) {
@@ -24,7 +24,7 @@ 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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{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")
}
@@ -49,7 +49,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}

View File

@@ -4,9 +4,9 @@ import (
"fmt"
"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/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Telegram
@@ -14,7 +14,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"`
}
@@ -24,12 +24,12 @@ func (provider *AlertProvider) IsValid() bool {
}
// 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 {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
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

View File

@@ -7,8 +7,8 @@ import (
"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 TestAlertProvider_IsValid(t *testing.T) {
@@ -24,7 +24,7 @@ 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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -48,7 +48,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &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")
}
@@ -70,7 +70,7 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
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)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}

View File

@@ -6,9 +6,9 @@ import (
"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/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -18,7 +18,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"`
}
@@ -28,12 +28,12 @@ func (provider *AlertProvider) IsValid() bool {
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
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),

View File

@@ -5,8 +5,8 @@ import (
"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) {
@@ -33,7 +33,7 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
To: "4",
}
description := "alert-description"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -46,8 +46,8 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
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)
if customAlertProvider.Body != "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4" {
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
}
}
@@ -59,7 +59,7 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
To: "1",
}
description := "alert-description"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -72,7 +72,7 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
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 customAlertProvider.Body != "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1" {
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
}
}

View File

@@ -22,7 +22,7 @@ func GetHTTPClient(config *Config) *http.Client {
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 +38,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 +61,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 +85,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 {

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,
}
} else {
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
return err
}
}
err := storage.Initialize(config.Storage)
if err != nil {
return err
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
}
}
// 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 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
@@ -255,13 +289,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,146 @@
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
timeLocation *time.Location
}
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,23 +34,21 @@ 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)
@@ -61,6 +59,44 @@ func uptimeBadgeHandler(writer http.ResponseWriter, request *http.Request) {
_, _ = 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
}
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 generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
switch duration {
@@ -127,46 +163,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{
@@ -116,7 +113,7 @@ func responseTimeChartHandler(writer http.ResponseWriter, r *http.Request) {
}
writer.Header().Set("Content-Type", "image/svg+xml")
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 {
Name string
Condition Condition
Result *Result
ExpectedSuccess bool
ExpectedOutput string
}
scenarios := []scenario{
scenarios := []struct {
Name string
Condition Condition
Result *Result
DontResolveFailedConditions bool
ExpectedSuccess bool
ExpectedOutput string
}{
{
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...")
}
}

299
core/endpoint.go Normal file
View File

@@ -0,0 +1,299 @@
package core
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"io/ioutil"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core/ui"
"github.com/TwiN/gatus/v3/util"
)
const (
// HostHeader is the name of the header used to specify the host
HostHeader = "Host"
// ContentTypeHeader is the name of the header used to specify the content type
ContentTypeHeader = "Content-Type"
// UserAgentHeader is the name of the header used to specify the request's user agent
UserAgentHeader = "User-Agent"
// GatusUserAgent is the default user agent that Gatus uses to send requests.
GatusUserAgent = "Gatus/1.0"
)
var (
// ErrEndpointWithNoCondition is the error with which Gatus will panic if an endpoint is configured with no conditions
ErrEndpointWithNoCondition = errors.New("you must specify at least one condition per endpoint")
// ErrEndpointWithNoURL is the error with which Gatus will panic if an endpoint is configured with no url
ErrEndpointWithNoURL = errors.New("you must specify an url for each endpoint")
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
)
// Endpoint is the configuration of a monitored
type Endpoint struct {
// Enabled defines whether to enable the monitoring of the endpoint
Enabled *bool `yaml:"enabled,omitempty"`
// Name of the endpoint. Can be anything.
Name string `yaml:"name"`
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
Group string `yaml:"group,omitempty"`
// URL to send the request to
URL string `yaml:"url"`
// DNS is the configuration of DNS monitoring
DNS *DNS `yaml:"dns,omitempty"`
// Method of the request made to the url of the endpoint
Method string `yaml:"method,omitempty"`
// Body of the request
Body string `yaml:"body,omitempty"`
// GraphQL is whether to wrap the body in a query param ({"query":"$body"})
GraphQL bool `yaml:"graphql,omitempty"`
// Headers of the request
Headers map[string]string `yaml:"headers,omitempty"`
// Interval is the duration to wait between every status check
Interval time.Duration `yaml:"interval,omitempty"`
// Conditions used to determine the health of the endpoint
Conditions []*Condition `yaml:"conditions"`
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the endpoint's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// UIConfig is the configuration for the UI
UIConfig *ui.Config `yaml:"ui,omitempty"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int `yaml:"-"`
// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`
}
// IsEnabled returns whether the endpoint is enabled or not
func (endpoint Endpoint) IsEnabled() bool {
if endpoint.Enabled == nil {
return true
}
return *endpoint.Enabled
}
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of fields that have one
func (endpoint *Endpoint) ValidateAndSetDefaults() error {
// Set default values
if endpoint.ClientConfig == nil {
endpoint.ClientConfig = client.GetDefaultConfig()
} else {
endpoint.ClientConfig.ValidateAndSetDefaults()
}
if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig()
}
if endpoint.Interval == 0 {
endpoint.Interval = 1 * time.Minute
}
if len(endpoint.Method) == 0 {
endpoint.Method = http.MethodGet
}
if len(endpoint.Headers) == 0 {
endpoint.Headers = make(map[string]string)
}
// Automatically add user agent header if there isn't one specified in the endpoint configuration
if _, userAgentHeaderExists := endpoint.Headers[UserAgentHeader]; !userAgentHeaderExists {
endpoint.Headers[UserAgentHeader] = GatusUserAgent
}
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
// and endpoint.GraphQL is set to true
if _, contentTypeHeaderExists := endpoint.Headers[ContentTypeHeader]; !contentTypeHeaderExists && endpoint.GraphQL {
endpoint.Headers[ContentTypeHeader] = "application/json"
}
for _, endpointAlert := range endpoint.Alerts {
if endpointAlert.FailureThreshold <= 0 {
endpointAlert.FailureThreshold = 3
}
if endpointAlert.SuccessThreshold <= 0 {
endpointAlert.SuccessThreshold = 2
}
}
if len(endpoint.Name) == 0 {
return ErrEndpointWithNoName
}
if len(endpoint.URL) == 0 {
return ErrEndpointWithNoURL
}
if len(endpoint.Conditions) == 0 {
return ErrEndpointWithNoCondition
}
if endpoint.DNS != nil {
return endpoint.DNS.validateAndSetDefault()
}
// Make sure that the request can be created
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body)))
if err != nil {
return err
}
return nil
}
// Key returns the unique key for the Endpoint
func (endpoint Endpoint) Key() string {
return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name)
}
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (endpoint *Endpoint) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}}
endpoint.getIP(result)
if len(result.Errors) == 0 {
endpoint.call(result)
} else {
result.Success = false
}
for _, condition := range endpoint.Conditions {
success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions)
if !success {
result.Success = false
}
}
result.Timestamp = time.Now()
// No need to keep the body after the endpoint has been evaluated
result.body = nil
// Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideHostname {
result.Hostname = ""
}
return result
}
func (endpoint *Endpoint) getIP(result *Result) {
if endpoint.DNS != nil {
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53")
} else {
urlObject, err := url.Parse(endpoint.URL)
if err != nil {
result.AddError(err.Error())
return
}
result.Hostname = urlObject.Hostname()
}
ips, err := net.LookupIP(result.Hostname)
if err != nil {
result.AddError(err.Error())
return
}
result.IP = ips[0].String()
}
func (endpoint *Endpoint) call(result *Result) {
var request *http.Request
var response *http.Response
var err error
var certificate *x509.Certificate
isTypeDNS := endpoint.DNS != nil
isTypeTCP := strings.HasPrefix(endpoint.URL, "tcp://")
isTypeICMP := strings.HasPrefix(endpoint.URL, "icmp://")
isTypeSTARTTLS := strings.HasPrefix(endpoint.URL, "starttls://")
isTypeTLS := strings.HasPrefix(endpoint.URL, "tls://")
isTypeHTTP := !isTypeDNS && !isTypeTCP && !isTypeICMP && !isTypeSTARTTLS && !isTypeTLS
if isTypeHTTP {
request = endpoint.buildHTTPRequest()
}
startTime := time.Now()
if isTypeDNS {
endpoint.DNS.query(endpoint.URL, result)
result.Duration = time.Since(startTime)
} else if isTypeSTARTTLS || isTypeTLS {
if isTypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
} else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
}
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if isTypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig)
result.Duration = time.Since(startTime)
} else if isTypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
} else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)
result.Duration = time.Since(startTime)
if err != nil {
result.AddError(err.Error())
return
}
defer response.Body.Close()
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
certificate = response.TLS.PeerCertificates[0]
result.CertificateExpiration = time.Until(certificate.NotAfter)
}
result.HTTPStatus = response.StatusCode
result.Connected = response.StatusCode > 0
// Only read the body if there's a condition that uses the BodyPlaceholder
if endpoint.needsToReadBody() {
result.body, err = ioutil.ReadAll(response.Body)
if err != nil {
result.AddError(err.Error())
}
}
}
}
func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer
if endpoint.GraphQL {
graphQlBody := map[string]string{
"query": endpoint.Body,
}
body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body)
} else {
bodyBuffer = bytes.NewBuffer([]byte(endpoint.Body))
}
request, _ := http.NewRequest(endpoint.Method, endpoint.URL, bodyBuffer)
for k, v := range endpoint.Headers {
request.Header.Set(k, v)
if k == HostHeader {
request.Host = v
}
}
return request
}
// needsToReadBody checks if there's any conditions that requires the response body to be read
func (endpoint *Endpoint) needsToReadBody() bool {
for _, condition := range endpoint.Conditions {
if condition.hasBodyPlaceholder() {
return true
}
}
return false
}

40
core/endpoint_status.go Normal file
View File

@@ -0,0 +1,40 @@
package core
import "github.com/TwiN/gatus/v3/util"
// EndpointStatus contains the evaluation Results of an Endpoint
type EndpointStatus struct {
// Name of the endpoint
Name string `json:"name,omitempty"`
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
Group string `json:"group,omitempty"`
// Key is the key representing the EndpointStatus
Key string `json:"key"`
// Results is the list of endpoint evaluation results
Results []*Result `json:"results"`
// Events is a list of events
Events []*Event `json:"events,omitempty"`
// Uptime information on the endpoint's uptime
//
// Used by the memory store.
//
// To retrieve the uptime between two time, use store.GetUptimeByKey.
Uptime *Uptime `json:"-"`
}
// NewEndpointStatus creates a new EndpointStatus
func NewEndpointStatus(group, name string) *EndpointStatus {
return &EndpointStatus{
Name: name,
Group: group,
Key: util.ConvertGroupAndEndpointNameToKey(group, name),
Results: make([]*Result, 0),
Events: make([]*Event, 0),
Uptime: NewUptime(),
}
}

View File

@@ -0,0 +1,19 @@
package core
import (
"testing"
)
func TestNewEndpointStatus(t *testing.T) {
endpoint := &Endpoint{Name: "name", Group: "group"}
status := NewEndpointStatus(endpoint.Group, endpoint.Name)
if status.Name != endpoint.Name {
t.Errorf("expected %s, got %s", endpoint.Name, status.Name)
}
if status.Group != endpoint.Group {
t.Errorf("expected %s, got %s", endpoint.Group, status.Group)
}
if status.Key != "group_name" {
t.Errorf("expected %s, got %s", "group_name", status.Key)
}
}

View File

@@ -6,60 +6,72 @@ import (
"testing"
"time"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/client"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
)
func TestService_ValidateAndSetDefaults(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
func TestEndpoint_IsEnabled(t *testing.T) {
if !(Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
}
service.ValidateAndSetDefaults()
if service.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
if service.ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("Default client configuration should've set Insecure to %v, got %v", client.GetDefaultConfig().Insecure, service.ClientConfig.Insecure)
}
if service.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("Default client configuration should've set IgnoreRedirect to %v, got %v", client.GetDefaultConfig().IgnoreRedirect, service.ClientConfig.IgnoreRedirect)
}
if service.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("Default client configuration should've set Timeout to %v, got %v", client.GetDefaultConfig().Timeout, service.ClientConfig.Timeout)
}
if value := false; (Endpoint{Enabled: &value}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned false, because Enabled was set to false")
}
if service.Method != "GET" {
t.Error("Service method should've defaulted to GET")
}
if service.Interval != time.Minute {
t.Error("Service interval should've defaulted to 1 minute")
}
if service.Headers == nil {
t.Error("Service headers should've defaulted to an empty map")
}
if len(service.Alerts) != 1 {
t.Error("Service should've had 1 alert")
}
if service.Alerts[0].IsEnabled() {
t.Error("Service alert should've defaulted to disabled")
}
if service.Alerts[0].SuccessThreshold != 2 {
t.Error("Service alert should've defaulted to a success threshold of 2")
}
if service.Alerts[0].FailureThreshold != 3 {
t.Error("Service alert should've defaulted to a failure threshold of 3")
if value := true; !(Endpoint{Enabled: &value}).IsEnabled() {
t.Error("Endpoint.IsEnabled() should've returned true, because Enabled was set to true")
}
}
func TestService_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
}
endpoint.ValidateAndSetDefaults()
if endpoint.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
if endpoint.ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("Default client configuration should've set Insecure to %v, got %v", client.GetDefaultConfig().Insecure, endpoint.ClientConfig.Insecure)
}
if endpoint.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("Default client configuration should've set IgnoreRedirect to %v, got %v", client.GetDefaultConfig().IgnoreRedirect, endpoint.ClientConfig.IgnoreRedirect)
}
if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("Default client configuration should've set Timeout to %v, got %v", client.GetDefaultConfig().Timeout, endpoint.ClientConfig.Timeout)
}
}
if endpoint.Method != "GET" {
t.Error("Endpoint method should've defaulted to GET")
}
if endpoint.Interval != time.Minute {
t.Error("Endpoint interval should've defaulted to 1 minute")
}
if endpoint.Headers == nil {
t.Error("Endpoint headers should've defaulted to an empty map")
}
if len(endpoint.Alerts) != 1 {
t.Error("Endpoint should've had 1 alert")
}
if endpoint.Alerts[0].IsEnabled() {
t.Error("Endpoint alert should've defaulted to disabled")
}
if endpoint.Alerts[0].SuccessThreshold != 2 {
t.Error("Endpoint alert should've defaulted to a success threshold of 2")
}
if endpoint.Alerts[0].FailureThreshold != 3 {
t.Error("Endpoint alert should've defaulted to a failure threshold of 3")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
ClientConfig: &client.Config{
Insecure: true,
@@ -67,66 +79,66 @@ func TestService_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
Timeout: 0,
},
}
service.ValidateAndSetDefaults()
if service.ClientConfig == nil {
endpoint.ValidateAndSetDefaults()
if endpoint.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
if !service.ClientConfig.Insecure {
t.Error("service.ClientConfig.Insecure should've been set to true")
if !endpoint.ClientConfig.Insecure {
t.Error("endpoint.ClientConfig.Insecure should've been set to true")
}
if !service.ClientConfig.IgnoreRedirect {
t.Error("service.ClientConfig.IgnoreRedirect should've been set to true")
if !endpoint.ClientConfig.IgnoreRedirect {
t.Error("endpoint.ClientConfig.IgnoreRedirect should've been set to true")
}
if service.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Error("service.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid")
if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Error("endpoint.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid")
}
}
}
func TestService_ValidateAndSetDefaultsWithNoName(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaultsWithNoName(t *testing.T) {
defer func() { recover() }()
condition := Condition("[STATUS] == 200")
service := &Service{
endpoint := &Endpoint{
Name: "",
URL: "http://example.com",
Conditions: []*Condition{&condition},
}
err := service.ValidateAndSetDefaults()
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because service didn't have a name, which is a mandatory field")
t.Fatal("Should've returned an error because endpoint didn't have a name, which is a mandatory field")
}
}
func TestService_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
defer func() { recover() }()
condition := Condition("[STATUS] == 200")
service := &Service{
endpoint := &Endpoint{
Name: "example",
URL: "",
Conditions: []*Condition{&condition},
}
err := service.ValidateAndSetDefaults()
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because service didn't have an url, which is a mandatory field")
t.Fatal("Should've returned an error because endpoint didn't have an url, which is a mandatory field")
}
}
func TestService_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
defer func() { recover() }()
service := &Service{
endpoint := &Endpoint{
Name: "example",
URL: "http://example.com",
Conditions: nil,
}
err := service.ValidateAndSetDefaults()
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because service didn't have at least 1 condition")
t.Fatal("Should've returned an error because endpoint didn't have at least 1 condition")
}
}
func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
service := &Service{
endpoint := &Endpoint{
Name: "dns-test",
URL: "http://example.com",
DNS: &DNS{
@@ -135,71 +147,71 @@ func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
},
Conditions: []*Condition{&conditionSuccess},
}
err := service.ValidateAndSetDefaults()
err := endpoint.ValidateAndSetDefaults()
if err != nil {
}
if service.DNS.QueryName != "example.com." {
t.Error("Service.dns.query-name should be formatted with . suffix")
if endpoint.DNS.QueryName != "example.com." {
t.Error("Endpoint.dns.query-name should be formatted with . suffix")
}
}
func TestService_buildHTTPRequest(t *testing.T) {
func TestEndpoint_buildHTTPRequest(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
}
service.ValidateAndSetDefaults()
request := service.buildHTTPRequest()
endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest()
if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method)
}
if request.Host != "twinnation.org" {
t.Error("request.Host should've been twinnation.org, but was", request.Host)
if request.Host != "twin.sh" {
t.Error("request.Host should've been twin.sh, but was", request.Host)
}
if userAgent := request.Header.Get("User-Agent"); userAgent != GatusUserAgent {
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", GatusUserAgent, userAgent)
}
}
func TestService_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Headers: map[string]string{
"User-Agent": "Test/2.0",
},
}
service.ValidateAndSetDefaults()
request := service.buildHTTPRequest()
endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest()
if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method)
}
if request.Host != "twinnation.org" {
t.Error("request.Host should've been twinnation.org, but was", request.Host)
if request.Host != "twin.sh" {
t.Error("request.Host should've been twin.sh, but was", request.Host)
}
if userAgent := request.Header.Get("User-Agent"); userAgent != "Test/2.0" {
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", "Test/2.0", userAgent)
}
}
func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []*Condition{&condition},
Headers: map[string]string{
"Host": "example.com",
},
}
service.ValidateAndSetDefaults()
request := service.buildHTTPRequest()
endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest()
if request.Method != "POST" {
t.Error("request.Method should've been POST, but was", request.Method)
}
@@ -208,11 +220,11 @@ func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
}
}
func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-graphql",
URL: "https://twinnation.org/graphql",
endpoint := Endpoint{
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []*Condition{&condition},
GraphQL: true,
@@ -225,8 +237,8 @@ func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
}
}`,
}
service.ValidateAndSetDefaults()
request := service.buildHTTPRequest()
endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest()
if request.Method != "POST" {
t.Error("request.Method should've been POST, but was", request.Method)
}
@@ -242,13 +254,13 @@ func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
func TestIntegrationEvaluateHealth(t *testing.T) {
condition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition, &bodyCondition},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
@@ -262,13 +274,13 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
@@ -283,7 +295,7 @@ func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
conditionBody := Condition("[BODY] == 93.184.216.34")
service := Service{
endpoint := Endpoint{
Name: "example",
URL: "8.8.8.8",
DNS: &DNS{
@@ -292,8 +304,8 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
},
Conditions: []*Condition{&conditionSuccess, &conditionBody},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' and %s should have been a success", conditionSuccess, conditionBody)
}
@@ -307,13 +319,13 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
service := Service{
endpoint := Endpoint{
Name: "icmp-test",
URL: "icmp://127.0.0.1",
Conditions: []*Condition{&conditionSuccess},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' should have been a success", conditionSuccess)
}
@@ -325,40 +337,40 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
}
}
func TestService_getIP(t *testing.T) {
func TestEndpoint_getIP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
service := Service{
endpoint := Endpoint{
Name: "invalid-url-test",
URL: "",
Conditions: []*Condition{&conditionSuccess},
}
result := &Result{}
service.getIP(result)
endpoint.getIP(result)
if len(result.Errors) == 0 {
t.Error("service.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed")
t.Error("endpoint.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed")
}
}
func TestService_NeedsToReadBody(t *testing.T) {
func TestEndpoint_NeedsToReadBody(t *testing.T) {
statusCondition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
if (&Service{Conditions: []*Condition{&statusCondition}}).needsToReadBody() {
if (&Endpoint{Conditions: []*Condition{&statusCondition}}).needsToReadBody() {
t.Error("expected false, got true")
}
if !(&Service{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Service{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() {
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Service{Conditions: []*Condition{&statusCondition, &bodyCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []*Condition{&statusCondition, &bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Service{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Service{Conditions: []*Condition{&bodyConditionWithLength, &statusCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength, &statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
}

View File

@@ -15,13 +15,13 @@ type Event struct {
type EventType string
var (
// EventStart is a type of event that represents when a service starts being monitored
// EventStart is a type of event that represents when an endpoint starts being monitored
EventStart EventType = "START"
// EventHealthy is a type of event that represents a service passing all of its conditions
// EventHealthy is a type of event that represents an endpoint passing all of its conditions
EventHealthy EventType = "HEALTHY"
// EventUnhealthy is a type of event that represents a service failing one or more of its conditions
// EventUnhealthy is a type of event that represents an endpoint failing one or more of its conditions
EventUnhealthy EventType = "UNHEALTHY"
)

12
core/event_test.go Normal file
View File

@@ -0,0 +1,12 @@
package core
import "testing"
func TestNewEventFromResult(t *testing.T) {
if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy {
t.Error("expected event.Type to be EventHealthy")
}
if event := NewEventFromResult(&Result{Success: false}); event.Type != EventUnhealthy {
t.Error("expected event.Type to be EventUnhealthy")
}
}

View File

@@ -4,18 +4,18 @@ import (
"time"
)
// Result of the evaluation of a Service
// Result of the evaluation of a Endpoint
type Result struct {
// HTTPStatus is the HTTP response status code
HTTPStatus int `json:"status"`
// DNSRCode is the response code of a DNS query in a human readable format
// DNSRCode is the response code of a DNS query in a human-readable format
DNSRCode string `json:"-"`
// Hostname extracted from Service.URL
Hostname string `json:"hostname"`
// Hostname extracted from Endpoint.URL
Hostname string `json:"hostname,omitempty"`
// IP resolved from the Service URL
// IP resolved from the Endpoint URL
IP string `json:"-"`
// Connected whether a connection to the host was established successfully
@@ -24,10 +24,10 @@ type Result struct {
// Duration time that the request took
Duration time.Duration `json:"duration"`
// Errors encountered during the evaluation of the service's health
Errors []string `json:"errors"`
// Errors encountered during the evaluation of the Endpoint's health
Errors []string `json:"errors,omitempty"`
// ConditionResults results of the service's conditions
// ConditionResults results of the Endpoint's conditions
ConditionResults []*ConditionResult `json:"conditionResults"`
// Success whether the result signifies a success or not
@@ -41,8 +41,8 @@ type Result struct {
// body is the response body
//
// Note that this variable is only used during the evaluation of a service's health.
// This means that the call Service.EvaluateHealth both populates the body (if necessary)
// Note that this variable is only used during the evaluation of an Endpoint's health.
// This means that the call Endpoint.EvaluateHealth both populates the body (if necessary)
// and sets it to nil after the evaluation has been completed.
body []byte
}

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