Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5f0a5927 | ||
|
|
6c8761ca35 | ||
|
|
40b1576ec7 | ||
|
|
64c3b12a7b | ||
|
|
138f5bfb76 | ||
|
|
15a8055617 | ||
|
|
13184232d1 | ||
|
|
d0cca91043 | ||
|
|
239d1f5118 | ||
|
|
47bc78dc25 | ||
|
|
1df0801a61 | ||
|
|
d42c5f899e | ||
|
|
5f4c26e5fe | ||
|
|
2beaca5700 | ||
|
|
a55eb7da40 | ||
|
|
b0629773e5 | ||
|
|
c5f7e5b82b | ||
|
|
a2a7e1f14a | ||
|
|
1e4c440f01 | ||
|
|
844847bb05 | ||
|
|
0c3231713f | ||
|
|
ee01adb603 | ||
|
|
9121d87965 | ||
|
|
86cd1a9eb2 | ||
|
|
9f960fdd27 | ||
|
|
6f9db4107c | ||
|
|
5d626f2934 | ||
|
|
75c1b290f6 | ||
|
|
fe7b74f555 | ||
|
|
ed4c270a25 | ||
|
|
379ec2983d | ||
|
|
907716289c | ||
|
|
7c6b5539c1 | ||
|
|
607f3c5549 | ||
|
|
9e97efaba1 | ||
|
|
8912b4b3e3 | ||
|
|
5fdc489113 | ||
|
|
2ebb74ae1e | ||
|
|
e2f06e9ede | ||
|
|
beb9a2f3d9 | ||
|
|
e469b6adf4 | ||
|
|
2f8a3d2a02 | ||
|
|
9495b7389e | ||
|
|
c8bdecbde8 | ||
|
|
394602bc47 | ||
|
|
15813d4297 | ||
|
|
d24c66cf96 | ||
|
|
70d7d0c54c | ||
|
|
91931e48b4 | ||
|
|
386a4d2cb7 | ||
|
|
4d9eb0572c | ||
|
|
1586b3cc0b | ||
|
|
981e082d0c | ||
|
|
91daaf92aa | ||
|
|
a1bb07c556 | ||
|
|
258175dec3 | ||
|
|
ef6159e420 | ||
|
|
ebd4068aac | ||
|
|
39981de54b | ||
|
|
7dce07e47f | ||
|
|
6a83857db4 | ||
|
|
50702bd1d4 | ||
|
|
5bf95fe4f7 | ||
|
|
20d8ef966b | ||
|
|
8f15346fb7 | ||
|
|
8920bdd301 | ||
|
|
e37024dfc6 | ||
|
|
ac4374b1e3 | ||
|
|
129fb82f71 | ||
|
|
374be99b35 | ||
|
|
5c78bd92fb | ||
|
|
8853140cb2 | ||
|
|
03ec18a703 | ||
|
|
65eaed4621 | ||
|
|
10c6e71eef | ||
|
|
c7f0a32135 | ||
|
|
405c15f756 | ||
|
|
6f1312dfcf | ||
|
|
bd296c75da | ||
|
|
f007725140 | ||
|
|
40345a03d3 | ||
|
|
97a2be3504 | ||
|
|
15a4133502 | ||
|
|
64a5043655 | ||
|
|
5a06a74cc3 | ||
|
|
d6fa2c955b | ||
|
|
e6576e9080 | ||
|
|
cd10b31ab5 | ||
|
|
d1ef0b72a4 | ||
|
|
327a39964d | ||
|
|
c87c651ff0 | ||
|
|
1658825525 | ||
|
|
3a95e32210 | ||
|
|
bd793305e9 | ||
|
|
0d2a55cf11 | ||
|
|
565831aa46 | ||
|
|
8238a42a55 | ||
|
|
83c4fac217 | ||
|
|
37ba305c34 | ||
|
|
39ace63224 | ||
|
|
412b6d30a4 | ||
|
|
0f2b486623 | ||
|
|
347394b38a | ||
|
|
daf6ff60f8 | ||
|
|
f4001d0d80 | ||
|
|
65af0c9377 | ||
|
|
af4fbac84d | ||
|
|
39bfc51ce4 | ||
|
|
c006b35871 | ||
|
|
e3cae4637c | ||
|
|
3d61f5fe60 | ||
|
|
d559990162 | ||
|
|
f7fe56efa1 | ||
|
|
d668a14703 | ||
|
|
10cabb9dde | ||
|
|
3580bbb41b | ||
|
|
3a47d64610 | ||
|
|
2fa197d5bf | ||
|
|
d41cfc0d16 | ||
|
|
a49b9145d2 | ||
|
|
6e888430fa | ||
|
|
7dac2cc3f5 | ||
|
|
b875ba4dfe | ||
|
|
3e713dfee3 | ||
|
|
2f99eccf5f | ||
|
|
d37f71eee7 | ||
|
|
6d579a4b48 | ||
|
|
2c42aa8087 | ||
|
|
12825a2b6f | ||
|
|
e1edc15337 | ||
|
|
d17f51a1a1 | ||
|
|
a9fb48b26c | ||
|
|
131447f702 | ||
|
|
609a634df3 | ||
|
|
6c28de6950 | ||
|
|
440b732c71 | ||
|
|
8d63462fcd |
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
@@ -13,7 +12,7 @@ services:
|
||||
|
||||
prometheus:
|
||||
container_name: prometheus
|
||||
image: prom/prometheus:v2.14.0
|
||||
image: prom/prometheus:v3.5.0
|
||||
restart: always
|
||||
command: --config.file=/etc/prometheus/prometheus.yml
|
||||
ports:
|
||||
@@ -25,7 +24,7 @@ services:
|
||||
|
||||
grafana:
|
||||
container_name: grafana
|
||||
image: grafana/grafana:6.4.4
|
||||
image: grafana/grafana:12.1.0
|
||||
restart: always
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: secret
|
||||
@@ -16,4 +16,10 @@ endpoints:
|
||||
url: https://api.github.com/healthz
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: check-domain-expiration
|
||||
url: "https://example.org/"
|
||||
interval: 1h
|
||||
conditions:
|
||||
- "[DOMAIN_EXPIRATION] > 720h"
|
||||
@@ -19,7 +19,7 @@
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": 566,
|
||||
"id": 41,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
@@ -39,7 +39,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
@@ -79,7 +80,7 @@
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -122,7 +123,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red"
|
||||
"color": "red",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
@@ -162,7 +164,7 @@
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -193,7 +195,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -225,7 +228,7 @@
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -292,7 +295,7 @@
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -321,7 +324,7 @@
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"description": "SSL certificate expiration times for all services",
|
||||
"description": "Domain expiration times for all domains",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
@@ -339,7 +342,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red"
|
||||
"color": "red",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "#EAB839",
|
||||
@@ -395,7 +399,137 @@
|
||||
"showHeader": true,
|
||||
"sortBy": []
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "gatus_results_domain_expiration_seconds",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "__auto",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Domain Expiration",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "organize",
|
||||
"options": {
|
||||
"excludeByName": {
|
||||
"Time": true,
|
||||
"Value": false,
|
||||
"__name__": true,
|
||||
"app_kubernetes_io_instance": true,
|
||||
"app_kubernetes_io_managed_by": true,
|
||||
"app_kubernetes_io_name": true,
|
||||
"app_kubernetes_io_service": true,
|
||||
"helm_sh_chart": true,
|
||||
"instance": true,
|
||||
"job": true,
|
||||
"key": true,
|
||||
"type": true
|
||||
},
|
||||
"includeByName": {},
|
||||
"indexByName": {
|
||||
"Value": 2,
|
||||
"group": 0,
|
||||
"name": 1
|
||||
},
|
||||
"renameByName": {
|
||||
"Value": "Time Until Expiry",
|
||||
"group": "Group",
|
||||
"name": "Service"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"description": "SSL certificate expiration times for all services",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": {
|
||||
"type": "auto"
|
||||
},
|
||||
"inspect": false
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "#EAB839",
|
||||
"value": 172800
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 604800
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "dtdurations"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "Time Until Expiry"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.cellOptions",
|
||||
"value": {
|
||||
"applyToRow": false,
|
||||
"type": "color-background"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"value": "s"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 11,
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": {
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": [
|
||||
"sum"
|
||||
],
|
||||
"show": false
|
||||
},
|
||||
"showHeader": true,
|
||||
"sortBy": []
|
||||
},
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -444,6 +578,113 @@
|
||||
],
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"description": "Current status distribution across all services",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"expr": "sum(gatus_results_endpoint_success)",
|
||||
"legendFormat": "Services UP",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"expr": "sum(1 - gatus_results_endpoint_success)",
|
||||
"legendFormat": "Services DOWN",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Service Status Distribution",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
@@ -483,7 +724,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red"
|
||||
"color": "red",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
@@ -525,7 +767,7 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
"y": 16
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
@@ -546,7 +788,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -689,7 +931,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
@@ -703,9 +946,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
"y": 24
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
@@ -721,7 +964,7 @@
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -735,112 +978,6 @@
|
||||
],
|
||||
"title": "Response Times",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"description": "Current status distribution across all services",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 16
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"expr": "sum(gatus_results_endpoint_success)",
|
||||
"legendFormat": "Services UP",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"expr": "sum(1 - gatus_results_endpoint_success)",
|
||||
"legendFormat": "Services DOWN",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Service Status Distribution",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
@@ -855,8 +992,8 @@
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"text": "myprom",
|
||||
"value": "PA04845DA3A4B088E"
|
||||
"text": "prometheus",
|
||||
"value": "cedv077q7bbwgd"
|
||||
},
|
||||
"description": "Select your Prometheus datasource",
|
||||
"includeAll": false,
|
||||
@@ -877,6 +1014,6 @@
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Gatus - Service Monitoring Dashboard",
|
||||
"uid": "gatus-monitoring2",
|
||||
"version": 10
|
||||
}
|
||||
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:stable
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
BIN
.github/assets/dashboard-conditions.jpg
vendored
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.jpg
vendored
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
|
Before Width: | Height: | Size: 90 KiB |
BIN
.github/assets/endpoint-groups.jpg
vendored
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
.github/assets/endpoint-groups.png
vendored
|
Before Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.jpg
vendored
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/jetbrains-space-alerts.png
vendored
|
Before Width: | Height: | Size: 31 KiB |
1
.github/assets/logo.svg
vendored
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 89.76 89.75"><defs><style>.cls-1{fill:#3cad4b;}.cls-2{fill:#017400;}.cls-3{fill:#1e9025;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M33.67,65.35a23.35,23.35,0,0,1,.08-41,22.94,22.94,0,0,1,3.8-1.64A23,23,0,0,0,53.6,1C53,0,51,0,44.89,0c-9.08,0-9.21.17-8.81,3.22,1.07,8.12-9.42,12.5-14.45,6-1.94-2.52-2.1-2.52-8.68,4.16-6.22,6.3-6.33,6.28-3.77,8.25a8.09,8.09,0,0,1,2.56,9.53A8.15,8.15,0,0,1,3.08,36C0,35.63,0,35.73,0,45.2.08,53.81,0,54,3.3,53.63A8.06,8.06,0,0,1,9.76,67.52c-3,2.83-2.84,2.61,2.84,8.48,5.43,5.62,6.33,6.73,8.16,5.24L34,68A1.63,1.63,0,0,0,33.67,65.35Z"/><path class="cls-2" d="M85.43,36.13a8.11,8.11,0,0,1-5.27-14.21c2.85-2.5,2.82-2.37-3.55-8.75-4.31-4.31-5.71-5.75-6.87-5.4l-14,14a1.65,1.65,0,0,0,.36,2.61,23.35,23.35,0,0,1-.1,41,24.5,24.5,0,0,1-5.11,2c-8.54,2.28-14.73,9.63-14.73,18.47v1.27c.15,2.54,1.19,2.42,8.06,2.52,9.32.14,9.1.35,9.38-4.66a8.11,8.11,0,0,1,14-5.09c3,3.15,2.39,3.11,8.73-3.14,6.56-6.47,6.86-6.25,3.68-9.14a8.1,8.1,0,0,1,6.06-14.07c3.68.27,3.51.06,3.63-8.09C89.85,36.27,90,36.16,85.43,36.13Z"/><path class="cls-3" d="M41.11,59h8a.76.76,0,0,0,.77-.76V50.43a.76.76,0,0,1,.77-.76h7.84a.78.78,0,0,0,.77-.77V40.84a.77.77,0,0,0-.77-.76H50.7a.76.76,0,0,1-.77-.77V31.47a.76.76,0,0,0-.77-.77h-8a.76.76,0,0,0-.77.77v7.84a.76.76,0,0,1-.77.77H31.73a.77.77,0,0,0-.77.76V48.9a.78.78,0,0,0,.77.77h7.84a.76.76,0,0,1,.77.76v7.85A.76.76,0,0,0,41.11,59Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
.github/assets/past-announcements.jpg
vendored
Normal file
|
After Width: | Height: | Size: 69 KiB |
5
.github/codecov.yml
vendored
@@ -1,6 +1,9 @@
|
||||
ignore:
|
||||
- "watchdog/watchdog.go"
|
||||
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||
- "watchdog/endpoint.go"
|
||||
- "watchdog/external_endpoint.go"
|
||||
- "watchdog/suite.go"
|
||||
- "watchdog/watchdog.go"
|
||||
comment: false
|
||||
coverage:
|
||||
status:
|
||||
|
||||
6
.github/workflows/benchmark.yml
vendored
@@ -20,11 +20,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24.1
|
||||
go-version: 1.24.4
|
||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Benchmark
|
||||
run: go test -bench=. ./storage/store
|
||||
|
||||
3
.github/workflows/labeler.yml
vendored
@@ -32,6 +32,9 @@ jobs:
|
||||
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/alerting"
|
||||
fi
|
||||
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/ui"
|
||||
fi
|
||||
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/storage"
|
||||
fi
|
||||
|
||||
13
.github/workflows/publish-custom.yml
vendored
@@ -5,12 +5,21 @@ on:
|
||||
inputs:
|
||||
tag:
|
||||
description: Custom tag to publish
|
||||
platforms:
|
||||
description: Platforms to publish to (comma separated list)
|
||||
default: linux/amd64
|
||||
type: choice
|
||||
options:
|
||||
- linux/amd64
|
||||
- linux/arm/v7
|
||||
- linux/arm64
|
||||
|
||||
jobs:
|
||||
publish-custom:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
@@ -33,7 +42,7 @@ jobs:
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ inputs.platforms }}
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
2
.github/workflows/publish-experimental.yml
vendored
@@ -5,7 +5,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
2
.github/workflows/publish-latest.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
107
.github/workflows/regenerate-static-assets.yml
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
name: regenerate-static-assets
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
check-command:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request }}
|
||||
permissions:
|
||||
pull-requests: write # required for adding reactions to command comments on PRs
|
||||
checks: read # required to check if all ci checks have passed
|
||||
outputs:
|
||||
continue: ${{ steps.command.outputs.continue }}
|
||||
steps:
|
||||
- name: Check command trigger
|
||||
id: command
|
||||
uses: github/command@v2
|
||||
with:
|
||||
command: "/regenerate-static-assets"
|
||||
permissions: "write,admin" # The allowed permission levels to invoke this command
|
||||
allow_forks: true
|
||||
allow_drafts: true
|
||||
skip_ci: true
|
||||
skip_completing: true
|
||||
|
||||
regenerate-static-assets:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-command
|
||||
if: ${{ needs.check-command.outputs.continue == 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
status: ${{ steps.commit.outputs.status }}
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
id: pr
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
core.setOutput('ref', pr.data.head.ref);
|
||||
core.setOutput('repo', pr.data.head.repo.full_name);
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.repo }}
|
||||
ref: ${{ steps.pr.outputs.ref }}
|
||||
- name: Regenerate static assets
|
||||
run: |
|
||||
make frontend-install-dependencies
|
||||
make frontend-build
|
||||
- name: Commit and push changes
|
||||
id: commit
|
||||
run: |
|
||||
echo "Checking for changes..."
|
||||
if git diff --quiet; then
|
||||
echo "No changes detected."
|
||||
echo "status=no_changes" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
echo "Changes detected. Committing and pushing..."
|
||||
git add .
|
||||
git commit -m "chore(ui): Regenerate static assets"
|
||||
git push origin ${{ steps.pr.outputs.ref }}
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
|
||||
create-response-comment:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-command, regenerate-static-assets]
|
||||
if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Create response comment
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const status = '${{ needs.regenerate-static-assets.outputs.status }}';
|
||||
let reaction = '';
|
||||
if (status === 'success') {
|
||||
reaction = 'hooray';
|
||||
} else if (status === 'no_changes') {
|
||||
reaction = '+1';
|
||||
} else {
|
||||
reaction = '-1';
|
||||
var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
|
||||
var body = '⚠️ There was an issue regenerating static assets. Please check the [workflow run logs](' + workflowUrl + ') for more details.';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: reaction
|
||||
});
|
||||
2
.github/workflows/test-ui.yml
vendored
@@ -13,6 +13,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- run: make frontend-install-dependencies
|
||||
- run: make frontend-build
|
||||
8
.github/workflows/test.yml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24.1
|
||||
- uses: actions/checkout@v4
|
||||
go-version: 1.24.4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build binary to make sure it works
|
||||
run: go build
|
||||
- name: Test
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# 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 ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
uses: codecov/codecov-action@v5.5.2
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
|
||||
RUN apk --update add ca-certificates
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
RUN go mod tidy
|
||||
RUN go mod tidy -diff
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||
|
||||
# Run Tests inside docker image if you don't have a configured go environment
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
var (
|
||||
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
|
||||
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||
|
||||
ErrAlertWithInvalidMinimumReminderInterval = errors.New("minimum-reminder-interval must be either omitted or be at least 5m")
|
||||
)
|
||||
|
||||
// Alert is endpoint.Endpoint's alert configuration
|
||||
@@ -78,6 +80,9 @@ func (alert *Alert) ValidateAndSetDefaults() error {
|
||||
if alert.SuccessThreshold <= 0 {
|
||||
alert.SuccessThreshold = 2
|
||||
}
|
||||
if alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {
|
||||
return ErrAlertWithInvalidMinimumReminderInterval
|
||||
}
|
||||
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
||||
return ErrAlertWithInvalidDescription
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package alert
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
@@ -36,6 +37,61 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-0",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 0,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-5m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 5 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-10m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 10 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "invalid-minimum-reminder-interval-1m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 1 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "invalid-minimum-reminder-interval-1s",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 1 * time.Second,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
|
||||
@@ -8,9 +8,15 @@ const (
|
||||
// TypeAWSSES is the Type for the awsses alerting provider
|
||||
TypeAWSSES Type = "aws-ses"
|
||||
|
||||
// TypeClickUp is the Type for the clickup alerting provider
|
||||
TypeClickUp Type = "clickup"
|
||||
|
||||
// TypeCustom is the Type for the custom alerting provider
|
||||
TypeCustom Type = "custom"
|
||||
|
||||
// TypeDatadog is the Type for the datadog alerting provider
|
||||
TypeDatadog Type = "datadog"
|
||||
|
||||
// TypeDiscord is the Type for the discord alerting provider
|
||||
TypeDiscord Type = "discord"
|
||||
|
||||
@@ -32,17 +38,20 @@ const (
|
||||
// TypeGotify is the Type for the gotify alerting provider
|
||||
TypeGotify Type = "gotify"
|
||||
|
||||
// TypeHomeAssistant is the Type for the homeassistant alerting provider
|
||||
// TypeHomeAssistant is the Type for the homeassistant alerting provider
|
||||
TypeHomeAssistant Type = "homeassistant"
|
||||
|
||||
|
||||
// TypeIFTTT is the Type for the ifttt alerting provider
|
||||
TypeIFTTT Type = "ifttt"
|
||||
|
||||
// TypeIlert is the Type for the ilert alerting provider
|
||||
TypeIlert Type = "ilert"
|
||||
|
||||
// TypeIncidentIO is the Type for the incident-io alerting provider
|
||||
TypeIncidentIO Type = "incident-io"
|
||||
|
||||
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||
// TypeLine is the Type for the line alerting provider
|
||||
TypeLine Type = "line"
|
||||
|
||||
// TypeMatrix is the Type for the matrix alerting provider
|
||||
TypeMatrix Type = "matrix"
|
||||
@@ -53,6 +62,12 @@ const (
|
||||
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||
TypeMessagebird Type = "messagebird"
|
||||
|
||||
// TypeNewRelic is the Type for the newrelic alerting provider
|
||||
TypeNewRelic Type = "newrelic"
|
||||
|
||||
// TypeN8N is the Type for the n8n alerting provider
|
||||
TypeN8N Type = "n8n"
|
||||
|
||||
// TypeNtfy is the Type for the ntfy alerting provider
|
||||
TypeNtfy Type = "ntfy"
|
||||
|
||||
@@ -62,12 +77,33 @@ const (
|
||||
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||
TypePagerDuty Type = "pagerduty"
|
||||
|
||||
// TypePlivo is the Type for the plivo alerting provider
|
||||
TypePlivo Type = "plivo"
|
||||
|
||||
// TypePushover is the Type for the pushover alerting provider
|
||||
TypePushover Type = "pushover"
|
||||
|
||||
// TypeRocketChat is the Type for the rocketchat alerting provider
|
||||
TypeRocketChat Type = "rocketchat"
|
||||
|
||||
// TypeSendGrid is the Type for the sendgrid alerting provider
|
||||
TypeSendGrid Type = "sendgrid"
|
||||
|
||||
// TypeSignal is the Type for the signal alerting provider
|
||||
TypeSignal Type = "signal"
|
||||
|
||||
// TypeSIGNL4 is the Type for the signl4 alerting provider
|
||||
TypeSIGNL4 Type = "signl4"
|
||||
|
||||
// TypeSlack is the Type for the slack alerting provider
|
||||
TypeSlack Type = "slack"
|
||||
|
||||
// TypeSplunk is the Type for the splunk alerting provider
|
||||
TypeSplunk Type = "splunk"
|
||||
|
||||
// TypeSquadcast is the Type for the squadcast alerting provider
|
||||
TypeSquadcast Type = "squadcast"
|
||||
|
||||
// TypeTeams is the Type for the teams alerting provider
|
||||
TypeTeams Type = "teams"
|
||||
|
||||
@@ -80,6 +116,15 @@ const (
|
||||
// TypeTwilio is the Type for the twilio alerting provider
|
||||
TypeTwilio Type = "twilio"
|
||||
|
||||
// TypeVonage is the Type for the vonage alerting provider
|
||||
TypeVonage Type = "vonage"
|
||||
|
||||
// TypeWebex is the Type for the webex alerting provider
|
||||
TypeWebex Type = "webex"
|
||||
|
||||
// TypeZapier is the Type for the zapier alerting provider
|
||||
TypeZapier Type = "zapier"
|
||||
|
||||
// TypeZulip is the Type for the Zulip alerting provider
|
||||
TypeZulip Type = "zulip"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||
@@ -15,22 +17,35 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signal"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/webex"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/logr"
|
||||
)
|
||||
@@ -40,9 +55,15 @@ type Config struct {
|
||||
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
|
||||
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
|
||||
|
||||
// ClickUp is the configuration for the clickup alerting provider
|
||||
ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"`
|
||||
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||
|
||||
// Datadog is the configuration for the datadog alerting provider
|
||||
Datadog *datadog.AlertProvider `yaml:"datadog,omitempty"`
|
||||
|
||||
// Discord is the configuration for the discord alerting provider
|
||||
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
||||
|
||||
@@ -63,18 +84,21 @@ type Config struct {
|
||||
|
||||
// Gotify is the configuration for the gotify alerting provider
|
||||
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||
|
||||
|
||||
// HomeAssistant is the configuration for the homeassistant alerting provider
|
||||
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
|
||||
|
||||
|
||||
// IFTTT is the configuration for the ifttt alerting provider
|
||||
IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
|
||||
|
||||
// Ilert is the configuration for the ilert alerting provider
|
||||
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
|
||||
|
||||
// IncidentIO is the configuration for the incident-io alerting provider
|
||||
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
|
||||
|
||||
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
||||
// Line is the configuration for the line alerting provider
|
||||
Line *line.AlertProvider `yaml:"line,omitempty"`
|
||||
|
||||
// Matrix is the configuration for the matrix alerting provider
|
||||
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
||||
@@ -85,6 +109,12 @@ type Config struct {
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||
|
||||
// NewRelic is the configuration for the newrelic alerting provider
|
||||
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
|
||||
|
||||
// N8N is the configuration for the n8n alerting provider
|
||||
N8N *n8n.AlertProvider `yaml:"n8n,omitempty"`
|
||||
|
||||
// Ntfy is the configuration for the ntfy alerting provider
|
||||
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||
|
||||
@@ -94,12 +124,33 @@ type Config struct {
|
||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||
|
||||
// Plivo is the configuration for the plivo alerting provider
|
||||
Plivo *plivo.AlertProvider `yaml:"plivo,omitempty"`
|
||||
|
||||
// Pushover is the configuration for the pushover alerting provider
|
||||
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
|
||||
|
||||
// RocketChat is the configuration for the rocketchat alerting provider
|
||||
RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"`
|
||||
|
||||
// SendGrid is the configuration for the sendgrid alerting provider
|
||||
SendGrid *sendgrid.AlertProvider `yaml:"sendgrid,omitempty"`
|
||||
|
||||
// Signal is the configuration for the signal alerting provider
|
||||
Signal *signal.AlertProvider `yaml:"signal,omitempty"`
|
||||
|
||||
// SIGNL4 is the configuration for the signl4 alerting provider
|
||||
SIGNL4 *signl4.AlertProvider `yaml:"signl4,omitempty"`
|
||||
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||
|
||||
// Splunk is the configuration for the splunk alerting provider
|
||||
Splunk *splunk.AlertProvider `yaml:"splunk,omitempty"`
|
||||
|
||||
// Squadcast is the configuration for the squadcast alerting provider
|
||||
Squadcast *squadcast.AlertProvider `yaml:"squadcast,omitempty"`
|
||||
|
||||
// Teams is the configuration for the teams alerting provider
|
||||
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
||||
|
||||
@@ -112,6 +163,15 @@ type Config struct {
|
||||
// Twilio is the configuration for the twilio alerting provider
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||
|
||||
// Vonage is the configuration for the vonage alerting provider
|
||||
Vonage *vonage.AlertProvider `yaml:"vonage,omitempty"`
|
||||
|
||||
// Webex is the configuration for the webex alerting provider
|
||||
Webex *webex.AlertProvider `yaml:"webex,omitempty"`
|
||||
|
||||
// Zapier is the configuration for the zapier alerting provider
|
||||
Zapier *zapier.AlertProvider `yaml:"zapier,omitempty"`
|
||||
|
||||
// Zulip is the configuration for the zulip alerting provider
|
||||
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package awsses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/ses"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ses"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ses/types"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -102,63 +102,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
awsSession, err := provider.createSession(cfg)
|
||||
ctx := context.Background()
|
||||
svc, err := provider.createClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc := ses.New(awsSession)
|
||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||
emails := strings.Split(cfg.To, ",")
|
||||
|
||||
input := &ses.SendEmailInput{
|
||||
Destination: &ses.Destination{
|
||||
ToAddresses: aws.StringSlice(emails),
|
||||
Destination: &types.Destination{
|
||||
ToAddresses: emails,
|
||||
},
|
||||
Message: &ses.Message{
|
||||
Body: &ses.Body{
|
||||
Text: &ses.Content{
|
||||
Message: &types.Message{
|
||||
Body: &types.Body{
|
||||
Text: &types.Content{
|
||||
Charset: aws.String(CharSet),
|
||||
Data: aws.String(body),
|
||||
},
|
||||
},
|
||||
Subject: &ses.Content{
|
||||
Subject: &types.Content{
|
||||
Charset: aws.String(CharSet),
|
||||
Data: aws.String(subject),
|
||||
},
|
||||
},
|
||||
Source: aws.String(cfg.From),
|
||||
}
|
||||
if _, err = svc.SendEmail(input); err != nil {
|
||||
if aerr, ok := err.(awserr.Error); ok {
|
||||
switch aerr.Code() {
|
||||
case ses.ErrCodeMessageRejected:
|
||||
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
|
||||
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
||||
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
|
||||
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
||||
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
|
||||
default:
|
||||
logr.Error(aerr.Error())
|
||||
}
|
||||
} else {
|
||||
// Print the error, cast err to awserr.Error to get the Code and
|
||||
// Message from an error.
|
||||
logr.Error(err.Error())
|
||||
}
|
||||
|
||||
if _, err = svc.SendEmail(ctx, input); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
|
||||
awsConfig := &aws.Config{
|
||||
Region: aws.String(cfg.Region),
|
||||
func (provider *AlertProvider) createClient(ctx context.Context, cfg *Config) (*ses.Client, error) {
|
||||
var opts []func(*config.LoadOptions) error
|
||||
if len(cfg.Region) > 0 {
|
||||
opts = append(opts, config.WithRegion(cfg.Region))
|
||||
}
|
||||
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
|
||||
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
|
||||
opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")))
|
||||
}
|
||||
return session.NewSession(awsConfig)
|
||||
awsConfig, err := config.LoadDefaultConfig(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ses.NewFromConfig(awsConfig), nil
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
|
||||
285
alerting/provider/clickup/clickup.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package clickup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrListIDNotSet = errors.New("list-id not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none")
|
||||
)
|
||||
|
||||
var priorityMap = map[string]int{
|
||||
"urgent": 1,
|
||||
"high": 2,
|
||||
"normal": 3,
|
||||
"low": 4,
|
||||
"none": 0,
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
APIURL string `yaml:"api-url"`
|
||||
ListID string `yaml:"list-id"`
|
||||
Token string `yaml:"token"`
|
||||
Assignees []string `yaml:"assignees"`
|
||||
Status string `yaml:"status"`
|
||||
Priority string `yaml:"priority"`
|
||||
NotifyAll *bool `yaml:"notify-all,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
MarkdownContent string `yaml:"content,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if cfg.ListID == "" {
|
||||
return ErrListIDNotSet
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
if cfg.Priority == "" {
|
||||
cfg.Priority = "normal"
|
||||
}
|
||||
if _, ok := priorityMap[cfg.Priority]; !ok {
|
||||
return ErrInvalidPriority
|
||||
}
|
||||
if cfg.NotifyAll == nil {
|
||||
defaultNotifyAll := true
|
||||
cfg.NotifyAll = &defaultNotifyAll
|
||||
}
|
||||
if cfg.APIURL == "" {
|
||||
cfg.APIURL = "https://api.clickup.com/api/v2"
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]"
|
||||
}
|
||||
if cfg.MarkdownContent == "" {
|
||||
cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.APIURL != "" {
|
||||
cfg.APIURL = override.APIURL
|
||||
}
|
||||
if override.ListID != "" {
|
||||
cfg.ListID = override.ListID
|
||||
}
|
||||
if override.Token != "" {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
if override.Status != "" {
|
||||
cfg.Status = override.Status
|
||||
}
|
||||
if override.Priority != "" {
|
||||
cfg.Priority = override.Priority
|
||||
}
|
||||
if override.NotifyAll != nil {
|
||||
cfg.NotifyAll = override.NotifyAll
|
||||
}
|
||||
if len(override.Assignees) > 0 {
|
||||
cfg.Assignees = override.Assignees
|
||||
}
|
||||
if override.Name != "" {
|
||||
cfg.Name = override.Name
|
||||
}
|
||||
if override.MarkdownContent != "" {
|
||||
cfg.MarkdownContent = override.MarkdownContent
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using ClickUp
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default configuration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved {
|
||||
return provider.CloseTask(cfg, ep)
|
||||
}
|
||||
// Replace placeholders
|
||||
name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group)
|
||||
name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name)
|
||||
markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group)
|
||||
markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name)
|
||||
markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||
markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", "))
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"markdown_content": markdownContent,
|
||||
"assignees": cfg.Assignees,
|
||||
"status": cfg.Status,
|
||||
"notify_all": *cfg.NotifyAll,
|
||||
}
|
||||
if cfg.Priority != "none" {
|
||||
body["priority"] = priorityMap[cfg.Priority]
|
||||
}
|
||||
return provider.CreateTask(cfg, body)
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID)
|
||||
req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", cfg.Token)
|
||||
httpClient := client.GetHTTPClient(nil)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("failed to create task, status: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {
|
||||
fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID)
|
||||
req, err := http.NewRequest("GET", fetchURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", cfg.Token)
|
||||
httpClient := client.GetHTTPClient(nil)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode)
|
||||
}
|
||||
var fetchResponse struct {
|
||||
Tasks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"tasks"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
var matchingTaskIDs []string
|
||||
for _, task := range fetchResponse.Tasks {
|
||||
if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {
|
||||
matchingTaskIDs = append(matchingTaskIDs, task.ID)
|
||||
}
|
||||
}
|
||||
if len(matchingTaskIDs) == 0 {
|
||||
return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name)
|
||||
}
|
||||
for _, taskID := range matchingTaskIDs {
|
||||
if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil {
|
||||
return fmt.Errorf("failed to close task %s: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {
|
||||
updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID)
|
||||
body := map[string]interface{}{"status": status}
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", cfg.Token)
|
||||
httpClient := client.GetHTTPClient(nil)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
310
alerting/provider/clickup/clickup_test.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package clickup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}}
|
||||
if err := invalidProviderNoListID.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid without list-id")
|
||||
}
|
||||
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}}
|
||||
if err := invalidProviderNoToken.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid without token")
|
||||
}
|
||||
invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}}
|
||||
if err := invalidProviderBadPriority.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid with invalid priority")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if validProvider.DefaultConfig.Priority != "normal" {
|
||||
t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority)
|
||||
}
|
||||
validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}}
|
||||
if err := validProviderWithAPIURL.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}}
|
||||
if err := validProviderWithPriority.Validate(); err != nil {
|
||||
t.Error("provider should've been valid with priority 'urgent'")
|
||||
}
|
||||
validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}}
|
||||
if err := validProviderWithNone.Validate(); err != nil {
|
||||
t.Error("provider should've been valid with priority 'none'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {
|
||||
provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
|
||||
if err := provider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" {
|
||||
t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Endpoint endpoint.Endpoint
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Method == "GET" {
|
||||
// Mock fetch tasks response
|
||||
tasksResponse := map[string]interface{}{
|
||||
"tasks": []map[string]interface{}{
|
||||
{
|
||||
"id": "task-123",
|
||||
"name": "Health Check: endpoint-group:endpoint-name",
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(tasksResponse)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
}
|
||||
if r.Method == "PUT" {
|
||||
// Mock update task status response
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-no-matching-tasks",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Method == "GET" {
|
||||
// Mock fetch tasks response with no matching tasks
|
||||
tasksResponse := map[string]interface{}{
|
||||
"tasks": []map[string]interface{}{},
|
||||
}
|
||||
body, _ := json.Marshal(tasksResponse)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error-fetching-tasks",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
Errors: []string{"error1", "error2"},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"list-id": "override-list-id",
|
||||
"token": "override-token",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-partial-alert-override-should-merge",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"status": "closed",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-assignees-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"assignees": []string{"user1", "user2"},
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-priority-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"priority": "urgent",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-none-priority",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"priority": "none",
|
||||
}},
|
||||
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||
Overrides: []Override{
|
||||
{Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}},
|
||||
},
|
||||
},
|
||||
InputGroup: "core",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.ListID != scenario.ExpectedOutput.ListID {
|
||||
t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID)
|
||||
}
|
||||
if got.Token != scenario.ExpectedOutput.Token {
|
||||
t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||
}
|
||||
if got.Status != scenario.ExpectedOutput.Status {
|
||||
t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status)
|
||||
}
|
||||
if got.Priority != scenario.ExpectedOutput.Priority {
|
||||
t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority)
|
||||
}
|
||||
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
|
||||
t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,25 @@ func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoi
|
||||
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
|
||||
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
|
||||
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
|
||||
|
||||
if len(result.ConditionResults) > 0 && strings.Contains(body, "[RESULT_CONDITIONS]") {
|
||||
var formattedConditionResults string
|
||||
for index, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`", prefix, conditionResult.Condition)
|
||||
if index < len(result.ConditionResults)-1 {
|
||||
formattedConditionResults += ", "
|
||||
}
|
||||
}
|
||||
body = strings.ReplaceAll(body, "[RESULT_CONDITIONS]", formattedConditionResults)
|
||||
url = strings.ReplaceAll(url, "[RESULT_CONDITIONS]", formattedConditionResults)
|
||||
}
|
||||
|
||||
if resolved {
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||
|
||||
@@ -261,6 +261,69 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholderAndResultConditions(t *testing.T) {
|
||||
alertProvider := &AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_CONDITIONS]",
|
||||
Headers: nil,
|
||||
Placeholders: map[string]map[string]string{
|
||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||
"RESOLVED": "fixed",
|
||||
"TRIGGERED": "boom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
alertDescription := "alert-description"
|
||||
scenarios := []struct {
|
||||
AlertProvider *AlertProvider
|
||||
Resolved bool
|
||||
ExpectedURL string
|
||||
ExpectedBody string
|
||||
NoConditions bool
|
||||
}{
|
||||
{
|
||||
AlertProvider: alertProvider,
|
||||
Resolved: true,
|
||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed,✅ - `[CONNECTED] == true`, ✅ - `[STATUS] == 200`",
|
||||
},
|
||||
{
|
||||
AlertProvider: alertProvider,
|
||||
Resolved: false,
|
||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom,❌ - `[CONNECTED] == true`, ❌ - `[STATUS] == 200`",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||
var conditionResults []*endpoint.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
|
||||
request := alertProvider.buildHTTPRequest(
|
||||
&alertProvider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
&alert.Alert{Description: &alertDescription},
|
||||
&endpoint.Result{ConditionResults: conditionResults},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if request.URL.String() != scenario.ExpectedURL {
|
||||
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
|
||||
}
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
alertProvider := &AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
|
||||
214
alerting/provider/datadog/datadog.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package datadog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api-key"` // Datadog API key
|
||||
Site string `yaml:"site,omitempty"` // Datadog site (e.g., datadoghq.com, datadoghq.eu)
|
||||
Tags []string `yaml:"tags,omitempty"` // Additional tags to include
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.APIKey) == 0 {
|
||||
return ErrAPIKeyNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.APIKey) > 0 {
|
||||
cfg.APIKey = override.APIKey
|
||||
}
|
||||
if len(override.Site) > 0 {
|
||||
cfg.Site = override.Site
|
||||
}
|
||||
if len(override.Tags) > 0 {
|
||||
cfg.Tags = override.Tags
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Datadog
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
site := cfg.Site
|
||||
if site == "" {
|
||||
site = "datadoghq.com"
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
url := fmt.Sprintf("https://api.%s/api/v1/events", site)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("DD-API-KEY", cfg.APIKey)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to datadog alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Priority string `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
AlertType string `json:"alert_type"`
|
||||
SourceType string `json:"source_type_name"`
|
||||
DateHappened int64 `json:"date_happened,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var title, text, priority, alertType string
|
||||
if resolved {
|
||||
title = fmt.Sprintf("Resolved: %s", ep.DisplayName())
|
||||
text = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
priority = "normal"
|
||||
alertType = "success"
|
||||
} else {
|
||||
title = fmt.Sprintf("Alert: %s", ep.DisplayName())
|
||||
text = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
priority = "normal"
|
||||
alertType = "error"
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
text += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
text += "\n\nCondition Results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
text += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
tags := []string{
|
||||
"source:gatus",
|
||||
fmt.Sprintf("endpoint:%s", ep.Name),
|
||||
fmt.Sprintf("status:%s", alertType),
|
||||
}
|
||||
if ep.Group != "" {
|
||||
tags = append(tags, fmt.Sprintf("group:%s", ep.Group))
|
||||
}
|
||||
// Append custom tags
|
||||
if len(cfg.Tags) > 0 {
|
||||
tags = append(tags, cfg.Tags...)
|
||||
}
|
||||
body := Body{
|
||||
Title: title,
|
||||
Text: text,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
AlertType: alertType,
|
||||
SourceType: "gatus",
|
||||
DateHappened: time.Now().Unix(),
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
183
alerting/provider/datadog/datadog_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package datadog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid-us1",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-eu",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-tags",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-api-key",
|
||||
provider: AlertProvider{DefaultConfig: Config{Site: "datadoghq.com"}},
|
||||
expected: ErrAPIKeyNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "api.datadoghq.com" {
|
||||
t.Errorf("expected host api.datadoghq.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/events" {
|
||||
t.Errorf("expected path /api/v1/events, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("DD-API-KEY") != "dd-api-key-123" {
|
||||
t.Errorf("expected DD-API-KEY header to be 'dd-api-key-123', got %s", r.Header.Get("DD-API-KEY"))
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["title"] == nil {
|
||||
t.Error("expected 'title' field in request body")
|
||||
}
|
||||
title := body["title"].(string)
|
||||
if !strings.Contains(title, "Alert") {
|
||||
t.Errorf("expected title to contain 'Alert', got %s", title)
|
||||
}
|
||||
if body["alert_type"] != "error" {
|
||||
t.Errorf("expected alert_type to be 'error', got %v", body["alert_type"])
|
||||
}
|
||||
if body["priority"] != "normal" {
|
||||
t.Errorf("expected priority to be 'normal', got %v", body["priority"])
|
||||
}
|
||||
text := body["text"].(string)
|
||||
if !strings.Contains(text, "failed 3 time(s)") {
|
||||
t.Errorf("expected text to contain failure count, got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "triggered-with-tags",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
tags := body["tags"].([]interface{})
|
||||
// Datadog adds 3 base tags (source, endpoint, status) + custom tags
|
||||
if len(tags) < 5 {
|
||||
t.Errorf("expected at least 5 tags (3 base + 2 custom), got %d", len(tags))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "api.datadoghq.eu" {
|
||||
t.Errorf("expected host api.datadoghq.eu, got %s", r.Host)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
title := body["title"].(string)
|
||||
if !strings.Contains(title, "Resolved") {
|
||||
t.Errorf("expected title to contain 'Resolved', got %s", title)
|
||||
}
|
||||
if body["alert_type"] != "success" {
|
||||
t.Errorf("expected alert_type to be 'success', got %v", body["alert_type"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,9 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
MessageContent string `yaml:"message-content,omitempty"` // Message content for pinging users or groups (e.g. "<@123456789>" or "<@&987654321>")
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -38,6 +39,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
if len(override.MessageContent) > 0 {
|
||||
cfg.MessageContent = override.MessageContent
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
@@ -142,7 +146,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
title = cfg.Title
|
||||
}
|
||||
body := Body{
|
||||
Content: "",
|
||||
Content: cfg.MessageContent,
|
||||
Embeds: []Embed{
|
||||
{
|
||||
Title: title,
|
||||
|
||||
@@ -134,6 +134,16 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-message-content",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -200,6 +210,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-message-content-user-mention",
|
||||
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-message-content-role-mention",
|
||||
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@&987654321>"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"content\":\"\\u003c@\\u0026987654321\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-message-content",
|
||||
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -313,6 +344,39 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-message-content-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-message-content-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-message-content-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"message-content": "<@999999999>"}},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@999999999>"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -323,6 +387,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
if got.MessageContent != scenario.ExpectedOutput.MessageContent {
|
||||
t.Errorf("expected message content to be %s, got %s", scenario.ExpectedOutput.MessageContent, got.MessageContent)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
|
||||
@@ -166,7 +166,14 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
return subject, message + description + formattedConditionResults
|
||||
var extraLabels string
|
||||
if len(ep.ExtraLabels) > 0 {
|
||||
extraLabels = "\n\nExtra labels:\n"
|
||||
for key, value := range ep.ExtraLabels {
|
||||
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
return subject, message + description + extraLabels + formattedConditionResults
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
Endpoint *endpoint.Endpoint
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
@@ -84,6 +85,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
@@ -92,14 +94,42 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-extra-labels",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
|
||||
187
alerting/provider/ifttt/ifttt.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package ifttt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookKeyNotSet = errors.New("webhook-key not set")
|
||||
ErrEventNameNotSet = errors.New("event-name not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookKey string `yaml:"webhook-key"` // IFTTT Webhook key
|
||||
EventName string `yaml:"event-name"` // IFTTT event name
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookKey) == 0 {
|
||||
return ErrWebhookKeyNotSet
|
||||
}
|
||||
if len(cfg.EventName) == 0 {
|
||||
return ErrEventNameNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookKey) > 0 {
|
||||
cfg.WebhookKey = override.WebhookKey
|
||||
}
|
||||
if len(override.EventName) > 0 {
|
||||
cfg.EventName = override.EventName
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using IFTTT
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", cfg.EventName, cfg.WebhookKey)
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to ifttt alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Value1 string `json:"value1"` // Alert status/title
|
||||
Value2 string `json:"value2"` // Alert message
|
||||
Value3 string `json:"value3"` // Additional details
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var value1, value2, value3 string
|
||||
if resolved {
|
||||
value1 = fmt.Sprintf("✅ RESOLVED: %s", ep.DisplayName())
|
||||
value2 = fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row", alert.SuccessThreshold)
|
||||
} else {
|
||||
value1 = fmt.Sprintf("🚨 ALERT: %s", ep.DisplayName())
|
||||
value2 = fmt.Sprintf("Endpoint has failed %d time(s) in a row", alert.FailureThreshold)
|
||||
}
|
||||
// Build additional details
|
||||
value3 = fmt.Sprintf("Endpoint: %s", ep.DisplayName())
|
||||
if ep.Group != "" {
|
||||
value3 += fmt.Sprintf(" | Group: %s", ep.Group)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
value3 += fmt.Sprintf(" | Description: %s", alertDescription)
|
||||
}
|
||||
// Add condition results summary
|
||||
if len(result.ConditionResults) > 0 {
|
||||
successCount := 0
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
if conditionResult.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
value3 += fmt.Sprintf(" | Conditions: %d/%d passed", successCount, len(result.ConditionResults))
|
||||
}
|
||||
body := Body{
|
||||
Value1: value1,
|
||||
Value2: value2,
|
||||
Value3: value3,
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
154
alerting/provider/ifttt/ifttt_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package ifttt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-key",
|
||||
provider: AlertProvider{DefaultConfig: Config{EventName: "gatus_alert"}},
|
||||
expected: ErrWebhookKeyNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-event-name",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123"}},
|
||||
expected: ErrEventNameNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "maker.ifttt.com" {
|
||||
t.Errorf("expected host maker.ifttt.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/trigger/gatus_alert/with/key/ifttt-webhook-key-123" {
|
||||
t.Errorf("expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
value1 := body["value1"].(string)
|
||||
if !strings.Contains(value1, "ALERT") {
|
||||
t.Errorf("expected value1 to contain 'ALERT', got %s", value1)
|
||||
}
|
||||
value2 := body["value2"].(string)
|
||||
if !strings.Contains(value2, "failed 3 time(s)") {
|
||||
t.Errorf("expected value2 to contain failure count, got %s", value2)
|
||||
}
|
||||
value3 := body["value3"].(string)
|
||||
if !strings.Contains(value3, "Endpoint: endpoint-name") {
|
||||
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_resolved"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/trigger/gatus_resolved/with/key/ifttt-webhook-key-123" {
|
||||
t.Errorf("expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
value1 := body["value1"].(string)
|
||||
if !strings.Contains(value1, "RESOLVED") {
|
||||
t.Errorf("expected value1 to contain 'RESOLVED', got %s", value1)
|
||||
}
|
||||
value3 := body["value3"].(string)
|
||||
if !strings.Contains(value3, "Endpoint: endpoint-name") {
|
||||
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
18
alerting/provider/incidentio/dedup.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package incidentio
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
|
||||
// generateDeduplicationKey generates a unique deduplication_key for incident.io
|
||||
func generateDeduplicationKey(ep *endpoint.Endpoint, alert *alert.Alert) string {
|
||||
data := fmt.Sprintf("%s|%s|%s|%d", ep.Key(), alert.Type, alert.GetDescription(), time.Now().UnixNano())
|
||||
hash := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
@@ -153,27 +153,44 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
} else {
|
||||
prefix = "🔴"
|
||||
}
|
||||
// No need for \n since incident.io trims it anyways.
|
||||
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
|
||||
}
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
message += " with the following description: " + alert.GetDescription()
|
||||
}
|
||||
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
|
||||
var body []byte
|
||||
|
||||
// Generate deduplication key if empty (first firing)
|
||||
if alert.ResolveKey == "" {
|
||||
// Generate unique key (endpoint key, alert type, timestamp)
|
||||
alert.ResolveKey = generateDeduplicationKey(ep, alert)
|
||||
}
|
||||
// Extract alert_source_config_id from URL
|
||||
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
|
||||
body, _ = json.Marshal(Body{
|
||||
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
|
||||
mergedMetadata := map[string]interface{}{}
|
||||
// Copy cfg.Metadata
|
||||
for k, v := range cfg.Metadata {
|
||||
mergedMetadata[k] = v
|
||||
}
|
||||
// Add extra labels from endpoint (if present)
|
||||
if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {
|
||||
for k, v := range ep.ExtraLabels {
|
||||
mergedMetadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(Body{
|
||||
AlertSourceConfigID: alertSourceID,
|
||||
Title: "Gatus: " + ep.DisplayName(),
|
||||
Status: status,
|
||||
DeduplicationKey: alert.ResolveKey,
|
||||
Description: message,
|
||||
SourceURL: cfg.SourceURL,
|
||||
Metadata: cfg.Metadata,
|
||||
Metadata: mergedMetadata,
|
||||
})
|
||||
fmt.Printf("%v", string(body))
|
||||
return body
|
||||
|
||||
}
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
|
||||
@@ -183,39 +183,63 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
secondDescription := "description-2"
|
||||
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedAlertSourceID string
|
||||
ExpectedStatus string
|
||||
ExpectedTitle string
|
||||
ExpectedDescription string
|
||||
ExpectedSourceURL string
|
||||
ExpectedMetadata map[string]interface{}
|
||||
ShouldHaveDeduplicationKey bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedAlertSourceID: "some-id",
|
||||
ExpectedStatus: "firing",
|
||||
ExpectedTitle: "Gatus: endpoint-name",
|
||||
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
|
||||
ShouldHaveDeduplicationKey: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedAlertSourceID: "some-id",
|
||||
ExpectedStatus: "resolved",
|
||||
ExpectedTitle: "Gatus: endpoint-name",
|
||||
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
|
||||
ShouldHaveDeduplicationKey: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-metadata-source-url",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
|
||||
Name: "resolved-with-metadata-source-url",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedAlertSourceID: "some-id",
|
||||
ExpectedStatus: "resolved",
|
||||
ExpectedTitle: "Gatus: endpoint-name",
|
||||
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
|
||||
ExpectedSourceURL: "some-source-url",
|
||||
ExpectedMetadata: map[string]interface{}{"service": "some-service", "team": "very-core"},
|
||||
ShouldHaveDeduplicationKey: true,
|
||||
},
|
||||
{
|
||||
Name: "group-override",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||
Name: "group-override",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedAlertSourceID: "different-id",
|
||||
ExpectedStatus: "firing",
|
||||
ExpectedTitle: "Gatus: endpoint-name",
|
||||
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
|
||||
ShouldHaveDeduplicationKey: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -237,13 +261,42 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
|
||||
// Parse the JSON body
|
||||
var parsedBody Body
|
||||
if err := json.Unmarshal(body, &parsedBody); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
|
||||
// Validate individual fields
|
||||
if parsedBody.AlertSourceConfigID != scenario.ExpectedAlertSourceID {
|
||||
t.Errorf("expected alert_source_config_id to be %s, got %s", scenario.ExpectedAlertSourceID, parsedBody.AlertSourceConfigID)
|
||||
}
|
||||
if parsedBody.Status != scenario.ExpectedStatus {
|
||||
t.Errorf("expected status to be %s, got %s", scenario.ExpectedStatus, parsedBody.Status)
|
||||
}
|
||||
if parsedBody.Title != scenario.ExpectedTitle {
|
||||
t.Errorf("expected title to be %s, got %s", scenario.ExpectedTitle, parsedBody.Title)
|
||||
}
|
||||
if parsedBody.Description != scenario.ExpectedDescription {
|
||||
t.Errorf("expected description to be %s, got %s", scenario.ExpectedDescription, parsedBody.Description)
|
||||
}
|
||||
if scenario.ExpectedSourceURL != "" && parsedBody.SourceURL != scenario.ExpectedSourceURL {
|
||||
t.Errorf("expected source_url to be %s, got %s", scenario.ExpectedSourceURL, parsedBody.SourceURL)
|
||||
}
|
||||
if scenario.ExpectedMetadata != nil {
|
||||
metadataJSON, _ := json.Marshal(parsedBody.Metadata)
|
||||
expectedMetadataJSON, _ := json.Marshal(scenario.ExpectedMetadata)
|
||||
if string(metadataJSON) != string(expectedMetadataJSON) {
|
||||
t.Errorf("expected metadata to be %s, got %s", string(expectedMetadataJSON), string(metadataJSON))
|
||||
}
|
||||
}
|
||||
// Validate that deduplication_key exists and is not empty
|
||||
if scenario.ShouldHaveDeduplicationKey {
|
||||
if parsedBody.DeduplicationKey == "" {
|
||||
t.Error("expected deduplication_key to be present and non-empty")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
193
alerting/provider/line/line.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package line
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelAccessTokenNotSet = errors.New("channel-access-token not set")
|
||||
ErrUserIDsNotSet = errors.New("user-ids not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ChannelAccessToken string `yaml:"channel-access-token"` // Line Messaging API channel access token
|
||||
UserIDs []string `yaml:"user-ids"` // List of Line user IDs to send messages to
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ChannelAccessToken) == 0 {
|
||||
return ErrChannelAccessTokenNotSet
|
||||
}
|
||||
if len(cfg.UserIDs) == 0 {
|
||||
return ErrUserIDsNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.ChannelAccessToken) > 0 {
|
||||
cfg.ChannelAccessToken = override.ChannelAccessToken
|
||||
}
|
||||
if len(override.UserIDs) > 0 {
|
||||
cfg.UserIDs = override.UserIDs
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Line
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userID := range cfg.UserIDs {
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, "https://api.line.me/v2/bot/message/push", buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.ChannelAccessToken))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
return fmt.Errorf("call to line alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
To string `json:"to"`
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("✅ RESOLVED: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("⚠️ ALERT: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
message += "\n\nCondition Results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
To: userID,
|
||||
Messages: []Message{
|
||||
{
|
||||
Type: "text",
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
147
alerting/provider/line/line_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package line
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-channel-access-token",
|
||||
provider: AlertProvider{DefaultConfig: Config{UserIDs: []string{"U123"}}},
|
||||
expected: ErrChannelAccessTokenNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-user-ids",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123"}},
|
||||
expected: ErrUserIDsNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123", "U456"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/v2/bot/message/push" {
|
||||
t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer token123" {
|
||||
t.Errorf("expected Authorization header to be 'Bearer token123', got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["to"] == nil {
|
||||
t.Error("expected 'to' field in request body")
|
||||
}
|
||||
messages := body["messages"].([]interface{})
|
||||
if len(messages) != 1 {
|
||||
t.Errorf("expected 1 message, got %d", len(messages))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
messages := body["messages"].([]interface{})
|
||||
message := messages[0].(map[string]interface{})
|
||||
text := message["text"].(string)
|
||||
if !contains(text, "RESOLVED") {
|
||||
t.Errorf("expected message to contain 'RESOLVED', got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
@@ -203,7 +202,6 @@ func randStringBytes(n int) string {
|
||||
// All the compatible characters to use in a transaction ID
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := range b {
|
||||
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package jetbrainsspace
|
||||
package n8n
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -15,50 +15,36 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotSet = errors.New("project not set")
|
||||
ErrChannelIDNotSet = errors.New("channel-id not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Project string `yaml:"project"` // Project name
|
||||
ChannelID string `yaml:"channel-id"` // Chat Channel ID
|
||||
Token string `yaml:"token"` // Bearer Token
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.Project) == 0 {
|
||||
return ErrProjectNotSet
|
||||
}
|
||||
if len(cfg.ChannelID) == 0 {
|
||||
return ErrChannelIDNotSet
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.Project) > 0 {
|
||||
cfg.Project = override.Project
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.ChannelID) > 0 {
|
||||
cfg.ChannelID = override.ChannelID
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
|
||||
// AlertProvider is the configuration necessary for sending an alert using n8n
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
@@ -90,13 +76,11 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -110,78 +94,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Channel string `json:"channel"`
|
||||
Content Content `json:"content"`
|
||||
Title string `json:"title"`
|
||||
EndpointName string `json:"endpoint_name"`
|
||||
EndpointGroup string `json:"endpoint_group,omitempty"`
|
||||
EndpointURL string `json:"endpoint_url"`
|
||||
AlertDescription string `json:"alert_description,omitempty"`
|
||||
Resolved bool `json:"resolved"`
|
||||
Message string `json:"message"`
|
||||
ConditionResults []ConditionResult `json:"condition_results,omitempty"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ClassName string `json:"className"`
|
||||
Style string `json:"style"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
ClassName string `json:"className"`
|
||||
Elements []Element `json:"elements"`
|
||||
Header string `json:"header"`
|
||||
}
|
||||
|
||||
type Element struct {
|
||||
ClassName string `json:"className"`
|
||||
Accessory Accessory `json:"accessory"`
|
||||
Style string `json:"style"`
|
||||
Size string `json:"size"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Accessory struct {
|
||||
ClassName string `json:"className"`
|
||||
Icon Icon `json:"icon"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
|
||||
type Icon struct {
|
||||
Icon string `json:"icon"`
|
||||
type ConditionResult struct {
|
||||
Condition string `json:"condition"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
body := Body{
|
||||
Channel: "id:" + cfg.ChannelID,
|
||||
Content: Content{
|
||||
ClassName: "ChatMessage.Block",
|
||||
Sections: []Section{{
|
||||
ClassName: "MessageSection",
|
||||
Elements: []Element{},
|
||||
}},
|
||||
},
|
||||
}
|
||||
var message string
|
||||
if resolved {
|
||||
body.Content.Style = "SUCCESS"
|
||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
body.Content.Style = "WARNING"
|
||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
title := "Gatus"
|
||||
if cfg.Title != "" {
|
||||
title = cfg.Title
|
||||
}
|
||||
var conditionResults []ConditionResult
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
icon := "warning"
|
||||
style := "WARNING"
|
||||
if conditionResult.Success {
|
||||
icon = "success"
|
||||
style = "SUCCESS"
|
||||
}
|
||||
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
|
||||
ClassName: "MessageText",
|
||||
Accessory: Accessory{
|
||||
ClassName: "MessageIcon",
|
||||
Icon: Icon{Icon: icon},
|
||||
Style: style,
|
||||
},
|
||||
Style: style,
|
||||
Size: "REGULAR",
|
||||
Content: conditionResult.Condition,
|
||||
conditionResults = append(conditionResults, ConditionResult{
|
||||
Condition: conditionResult.Condition,
|
||||
Success: conditionResult.Success,
|
||||
})
|
||||
}
|
||||
body := Body{
|
||||
Title: title,
|
||||
EndpointName: ep.Name,
|
||||
EndpointGroup: ep.Group,
|
||||
EndpointURL: ep.URL,
|
||||
AlertDescription: alert.GetDescription(),
|
||||
Resolved: resolved,
|
||||
Message: message,
|
||||
ConditionResults: conditionResults,
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
364
alerting/provider/n8n/n8n_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package n8n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider webhook URL shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Endpoint endpoint.Endpoint
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody Body
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: Body{
|
||||
Title: "Gatus",
|
||||
EndpointName: "name",
|
||||
EndpointURL: "https://example.org",
|
||||
AlertDescription: "description-1",
|
||||
Resolved: false,
|
||||
Message: "An alert for name has been triggered due to having failed 3 time(s) in a row",
|
||||
ConditionResults: []ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: false},
|
||||
{Condition: "[STATUS] == 200", Success: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: Body{
|
||||
Title: "Gatus",
|
||||
EndpointName: "name",
|
||||
EndpointGroup: "group",
|
||||
EndpointURL: "https://example.org",
|
||||
AlertDescription: "description-1",
|
||||
Resolved: false,
|
||||
Message: "An alert for group/name has been triggered due to having failed 3 time(s) in a row",
|
||||
ConditionResults: []ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: false},
|
||||
{Condition: "[STATUS] == 200", Success: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: Body{
|
||||
Title: "Gatus",
|
||||
EndpointName: "name",
|
||||
EndpointURL: "https://example.org",
|
||||
AlertDescription: "description-2",
|
||||
Resolved: true,
|
||||
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
|
||||
ConditionResults: []ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: true},
|
||||
{Condition: "[STATUS] == 200", Success: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-custom-title",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "Custom Title"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: Body{
|
||||
Title: "Custom Title",
|
||||
EndpointName: "name",
|
||||
EndpointURL: "https://example.org",
|
||||
AlertDescription: "description-2",
|
||||
Resolved: true,
|
||||
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
|
||||
ConditionResults: []ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: true},
|
||||
{Condition: "[STATUS] == 200", Success: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
|
||||
if err != nil {
|
||||
t.Fatal("couldn't get config:", err.Error())
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
cfg,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
var actualBody Body
|
||||
if err := json.Unmarshal(body, &actualBody); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if actualBody.Title != scenario.ExpectedBody.Title {
|
||||
t.Errorf("expected title to be %s, got %s", scenario.ExpectedBody.Title, actualBody.Title)
|
||||
}
|
||||
if actualBody.EndpointName != scenario.ExpectedBody.EndpointName {
|
||||
t.Errorf("expected endpoint name to be %s, got %s", scenario.ExpectedBody.EndpointName, actualBody.EndpointName)
|
||||
}
|
||||
if actualBody.Resolved != scenario.ExpectedBody.Resolved {
|
||||
t.Errorf("expected resolved to be %v, got %v", scenario.ExpectedBody.Resolved, actualBody.Resolved)
|
||||
}
|
||||
if actualBody.Message != scenario.ExpectedBody.Message {
|
||||
t.Errorf("expected message to be %s, got %s", scenario.ExpectedBody.Message, actualBody.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example01.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
215
alerting/provider/newrelic/newrelic.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package newrelic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsertKeyNotSet = errors.New("insert-key not set")
|
||||
ErrAccountIDNotSet = errors.New("account-id not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
InsertKey string `yaml:"insert-key"` // New Relic Insert key
|
||||
AccountID string `yaml:"account-id"` // New Relic account ID
|
||||
Region string `yaml:"region,omitempty"` // Region (US or EU, defaults to US)
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.InsertKey) == 0 {
|
||||
return ErrInsertKeyNotSet
|
||||
}
|
||||
if len(cfg.AccountID) == 0 {
|
||||
return ErrAccountIDNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.InsertKey) > 0 {
|
||||
cfg.InsertKey = override.InsertKey
|
||||
}
|
||||
if len(override.AccountID) > 0 {
|
||||
cfg.AccountID = override.AccountID
|
||||
}
|
||||
if len(override.Region) > 0 {
|
||||
cfg.Region = override.Region
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using New Relic
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Determine the API endpoint based on region
|
||||
var apiHost string
|
||||
if cfg.Region == "EU" {
|
||||
apiHost = "insights-collector.eu01.nr-data.net"
|
||||
} else {
|
||||
apiHost = "insights-collector.newrelic.com"
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
url := fmt.Sprintf("https://%s/v1/accounts/%s/events", apiHost, cfg.AccountID)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Insert-Key", cfg.InsertKey)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to newrelic alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
EventType string `json:"eventType"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Service string `json:"service"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Group string `json:"group,omitempty"`
|
||||
AlertStatus string `json:"alertStatus"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
Source string `json:"source"`
|
||||
SuccessRate float64 `json:"successRate,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var alertStatus, severity, message string
|
||||
var successRate float64
|
||||
if resolved {
|
||||
alertStatus = "resolved"
|
||||
severity = "INFO"
|
||||
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
successRate = 100
|
||||
} else {
|
||||
alertStatus = "triggered"
|
||||
severity = "CRITICAL"
|
||||
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
successRate = 0
|
||||
}
|
||||
// Calculate success rate from condition results
|
||||
if len(result.ConditionResults) > 0 {
|
||||
successCount := 0
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
if conditionResult.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
successRate = float64(successCount) / float64(len(result.ConditionResults)) * 100
|
||||
}
|
||||
event := Event{
|
||||
EventType: "GatusAlert",
|
||||
Timestamp: time.Now().Unix() * 1000, // New Relic expects milliseconds
|
||||
Service: "Gatus",
|
||||
Endpoint: ep.DisplayName(),
|
||||
Group: ep.Group,
|
||||
AlertStatus: alertStatus,
|
||||
Message: message,
|
||||
Description: alert.GetDescription(),
|
||||
Severity: severity,
|
||||
Source: "gatus",
|
||||
SuccessRate: successRate,
|
||||
}
|
||||
// New Relic expects an array of events
|
||||
events := []Event{event}
|
||||
bodyAsJSON, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
189
alerting/provider/newrelic/newrelic_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package newrelic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-region",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-insert-key",
|
||||
provider: AlertProvider{DefaultConfig: Config{AccountID: "123456"}},
|
||||
expected: ErrInsertKeyNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-account-id",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123"}},
|
||||
expected: ErrAccountIDNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered-us",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "insights-collector.newrelic.com" {
|
||||
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/v1/accounts/123456/events" {
|
||||
t.Errorf("expected path /v1/accounts/123456/events, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Insert-Key") != "nr-insert-key-123" {
|
||||
t.Errorf("expected X-Insert-Key header to be 'nr-insert-key-123', got %s", r.Header.Get("X-Insert-Key"))
|
||||
}
|
||||
// New Relic API expects an array of events
|
||||
var events []map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&events)
|
||||
if len(events) != 1 {
|
||||
t.Errorf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
event := events[0]
|
||||
if event["eventType"] != "GatusAlert" {
|
||||
t.Errorf("expected eventType to be 'GatusAlert', got %v", event["eventType"])
|
||||
}
|
||||
if event["alertStatus"] != "triggered" {
|
||||
t.Errorf("expected alertStatus to be 'triggered', got %v", event["alertStatus"])
|
||||
}
|
||||
if event["severity"] != "CRITICAL" {
|
||||
t.Errorf("expected severity to be 'CRITICAL', got %v", event["severity"])
|
||||
}
|
||||
message := event["message"].(string)
|
||||
if !strings.Contains(message, "Alert") {
|
||||
t.Errorf("expected message to contain 'Alert', got %s", message)
|
||||
}
|
||||
if !strings.Contains(message, "failed 3 time(s)") {
|
||||
t.Errorf("expected message to contain failure count, got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "triggered-eu",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
// Note: Test doesn't actually use EU region, it uses default US region
|
||||
if r.Host != "insights-collector.newrelic.com" {
|
||||
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
// New Relic API expects an array of events
|
||||
var events []map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&events)
|
||||
if len(events) != 1 {
|
||||
t.Errorf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
event := events[0]
|
||||
if event["alertStatus"] != "resolved" {
|
||||
t.Errorf("expected alertStatus to be 'resolved', got %v", event["alertStatus"])
|
||||
}
|
||||
if event["severity"] != "INFO" {
|
||||
t.Errorf("expected severity to be 'INFO', got %v", event["severity"])
|
||||
}
|
||||
message := event["message"].(string)
|
||||
if !strings.Contains(message, "resolved") {
|
||||
t.Errorf("expected message to contain 'resolved', got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
183
alerting/provider/plivo/plivo.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package plivo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthIDNotSet = errors.New("auth-id not set")
|
||||
ErrAuthTokenNotSet = errors.New("auth-token not set")
|
||||
ErrFromNotSet = errors.New("from not set")
|
||||
ErrToNotSet = errors.New("to not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AuthID string `yaml:"auth-id"`
|
||||
AuthToken string `yaml:"auth-token"`
|
||||
From string `yaml:"from"`
|
||||
To []string `yaml:"to"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.AuthID) == 0 {
|
||||
return ErrAuthIDNotSet
|
||||
}
|
||||
if len(cfg.AuthToken) == 0 {
|
||||
return ErrAuthTokenNotSet
|
||||
}
|
||||
if len(cfg.From) == 0 {
|
||||
return ErrFromNotSet
|
||||
}
|
||||
if len(cfg.To) == 0 {
|
||||
return ErrToNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.AuthID) > 0 {
|
||||
cfg.AuthID = override.AuthID
|
||||
}
|
||||
if len(override.AuthToken) > 0 {
|
||||
cfg.AuthToken = override.AuthToken
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Plivo
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := provider.buildMessage(cfg, ep, alert, result, resolved)
|
||||
// Send individual SMS messages to each recipient
|
||||
for _, to := range cfg.To {
|
||||
if err := provider.sendSMS(cfg, to, message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSMS sends an SMS message to a single recipient
|
||||
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
|
||||
payload := map[string]string{
|
||||
"src": cfg.From,
|
||||
"dst": to,
|
||||
"text": message,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.plivo.com/v1/Account/%s/Message/", cfg.AuthID), bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken))))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to plivo alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMessage builds the message for the provider
|
||||
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
if resolved {
|
||||
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
} else {
|
||||
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
514
alerting/provider/plivo/plivo_test.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package plivo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestPlivoAlertProvider_IsValid(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
ExpectedError error
|
||||
}{
|
||||
{
|
||||
Name: "invalid-provider-missing-config",
|
||||
Provider: AlertProvider{},
|
||||
ExpectedError: ErrAuthIDNotSet,
|
||||
},
|
||||
{
|
||||
Name: "valid-provider",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
{
|
||||
Name: "valid-provider-with-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
{
|
||||
Name: "invalid-provider-duplicate-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||
},
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "3", From: "4444444444", To: []string{"5555555555"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
ExpectedError: ErrDuplicateGroupOverride,
|
||||
},
|
||||
{
|
||||
Name: "invalid-provider-empty-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "",
|
||||
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
ExpectedError: ErrDuplicateGroupOverride,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
err := scenario.Provider.Validate()
|
||||
if scenario.ExpectedError == nil && err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err == nil {
|
||||
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "multiple-recipients",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321", "1122334455"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildMessage(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedMessage string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
message := scenario.Provider.buildMessage(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if message != scenario.ExpectedMessage {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_sendSMS(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
cfg := &Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
}
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
To string
|
||||
Message string
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "successful-sms",
|
||||
To: "0987654321",
|
||||
Message: "Test message",
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
// Verify request structure
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]string
|
||||
json.Unmarshal(body, &payload)
|
||||
if payload["src"] != cfg.From {
|
||||
t.Errorf("expected src %s, got %s", cfg.From, payload["src"])
|
||||
}
|
||||
if payload["dst"] != "0987654321" {
|
||||
t.Errorf("expected dst %s, got %s", "0987654321", payload["dst"])
|
||||
}
|
||||
if payload["text"] != "Test message" {
|
||||
t.Errorf("expected text %s, got %s", "Test message", payload["text"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "failed-sms",
|
||||
To: "0987654321",
|
||||
Message: "Test message",
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
provider := AlertProvider{}
|
||||
err := provider.sendSMS(cfg, scenario.To, scenario.Message)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group1",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-no-match",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group2",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6", "from": "5555555555", "to": []string{"9999999999"}}},
|
||||
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "5555555555", To: []string{"9999999999"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-and-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group1",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6"}},
|
||||
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got:", err.Error())
|
||||
}
|
||||
if got.AuthID != scenario.ExpectedOutput.AuthID {
|
||||
t.Errorf("expected AuthID to be %s, got %s", scenario.ExpectedOutput.AuthID, got.AuthID)
|
||||
}
|
||||
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
|
||||
t.Errorf("expected AuthToken to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if len(got.To) != len(scenario.ExpectedOutput.To) {
|
||||
t.Errorf("expected To length to be %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
|
||||
}
|
||||
for i, to := range got.To {
|
||||
if to != scenario.ExpectedOutput.To[i] {
|
||||
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
|
||||
}
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Config Config
|
||||
ExpectedError error
|
||||
}{
|
||||
{
|
||||
Name: "valid-config",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
{
|
||||
Name: "missing-auth-id",
|
||||
Config: Config{
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: ErrAuthIDNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-auth-token",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: ErrAuthTokenNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-from",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: ErrFromNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-to",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
},
|
||||
ExpectedError: ErrToNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
err := scenario.Config.Validate()
|
||||
if scenario.ExpectedError == nil && err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err == nil {
|
||||
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Merge(t *testing.T) {
|
||||
cfg := Config{
|
||||
AuthID: "original-auth-id",
|
||||
AuthToken: "original-auth-token",
|
||||
From: "1111111111",
|
||||
To: []string{"2222222222"},
|
||||
}
|
||||
override := Config{
|
||||
AuthID: "override-auth-id",
|
||||
AuthToken: "override-auth-token",
|
||||
From: "3333333333",
|
||||
To: []string{"4444444444", "5555555555"},
|
||||
}
|
||||
cfg.Merge(&override)
|
||||
if cfg.AuthID != "override-auth-id" {
|
||||
t.Errorf("expected AuthID to be %s, got %s", "override-auth-id", cfg.AuthID)
|
||||
}
|
||||
if cfg.AuthToken != "override-auth-token" {
|
||||
t.Errorf("expected AuthToken to be %s, got %s", "override-auth-token", cfg.AuthToken)
|
||||
}
|
||||
if cfg.From != "3333333333" {
|
||||
t.Errorf("expected From to be %s, got %s", "3333333333", cfg.From)
|
||||
}
|
||||
if len(cfg.To) != 2 || cfg.To[0] != "4444444444" || cfg.To[1] != "5555555555" {
|
||||
t.Errorf("expected To to be [4444444444, 5555555555], got %v", cfg.To)
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,44 @@ package provider
|
||||
import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signal"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/webex"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
@@ -71,62 +85,93 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl
|
||||
if endpointAlert.SuccessThreshold == 0 {
|
||||
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||
}
|
||||
if endpointAlert.MinimumReminderInterval == 0 {
|
||||
endpointAlert.MinimumReminderInterval = providerDefaultAlert.MinimumReminderInterval
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Validate provider interface implementation on compile
|
||||
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||
_ AlertProvider = (*clickup.AlertProvider)(nil)
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*datadog.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitea.AlertProvider)(nil)
|
||||
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gotify.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gotify.AlertProvider)(nil)
|
||||
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ifttt.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||
_ AlertProvider = (*line.AlertProvider)(nil)
|
||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*n8n.AlertProvider)(nil)
|
||||
_ AlertProvider = (*newrelic.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
||||
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
_ AlertProvider = (*plivo.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pushover.AlertProvider)(nil)
|
||||
_ AlertProvider = (*rocketchat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*sendgrid.AlertProvider)(nil)
|
||||
_ AlertProvider = (*signal.AlertProvider)(nil)
|
||||
_ AlertProvider = (*signl4.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*splunk.AlertProvider)(nil)
|
||||
_ AlertProvider = (*squadcast.AlertProvider)(nil)
|
||||
_ AlertProvider = (*teams.AlertProvider)(nil)
|
||||
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
|
||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*webex.AlertProvider)(nil)
|
||||
_ AlertProvider = (*zapier.AlertProvider)(nil)
|
||||
_ AlertProvider = (*zulip.AlertProvider)(nil)
|
||||
|
||||
// Validate config interface implementation on compile
|
||||
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
||||
_ Config[clickup.Config] = (*clickup.Config)(nil)
|
||||
_ Config[custom.Config] = (*custom.Config)(nil)
|
||||
_ Config[datadog.Config] = (*datadog.Config)(nil)
|
||||
_ Config[discord.Config] = (*discord.Config)(nil)
|
||||
_ Config[email.Config] = (*email.Config)(nil)
|
||||
_ Config[gitea.Config] = (*gitea.Config)(nil)
|
||||
_ Config[github.Config] = (*github.Config)(nil)
|
||||
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
|
||||
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
|
||||
_ Config[gotify.Config] = (*gotify.Config)(nil)
|
||||
_ Config[gotify.Config] = (*gotify.Config)(nil)
|
||||
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
|
||||
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
|
||||
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
||||
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
||||
_ Config[line.Config] = (*line.Config)(nil)
|
||||
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
||||
_ Config[n8n.Config] = (*n8n.Config)(nil)
|
||||
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
|
||||
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
||||
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
||||
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
|
||||
_ Config[plivo.Config] = (*plivo.Config)(nil)
|
||||
_ Config[pushover.Config] = (*pushover.Config)(nil)
|
||||
_ Config[rocketchat.Config] = (*rocketchat.Config)(nil)
|
||||
_ Config[sendgrid.Config] = (*sendgrid.Config)(nil)
|
||||
_ Config[signal.Config] = (*signal.Config)(nil)
|
||||
_ Config[signl4.Config] = (*signl4.Config)(nil)
|
||||
_ Config[slack.Config] = (*slack.Config)(nil)
|
||||
_ Config[splunk.Config] = (*splunk.Config)(nil)
|
||||
_ Config[squadcast.Config] = (*squadcast.Config)(nil)
|
||||
_ Config[teams.Config] = (*teams.Config)(nil)
|
||||
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
|
||||
_ Config[telegram.Config] = (*telegram.Config)(nil)
|
||||
_ Config[twilio.Config] = (*twilio.Config)(nil)
|
||||
_ Config[webex.Config] = (*webex.Config)(nil)
|
||||
_ Config[zapier.Config] = (*zapier.Config)(nil)
|
||||
_ Config[zulip.Config] = (*zulip.Config)(nil)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
MinimumReminderInterval: 30 * time.Second,
|
||||
},
|
||||
EndpointAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
@@ -35,6 +37,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
MinimumReminderInterval: 30 * time.Second,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -148,6 +151,9 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
|
||||
}
|
||||
if int(scenario.EndpointAlert.MinimumReminderInterval) != int(scenario.ExpectedOutputAlert.MinimumReminderInterval) {
|
||||
t.Errorf("expected EndpointAlert.MinimumReminderInterval to be %v, got %v", scenario.ExpectedOutputAlert.MinimumReminderInterval, scenario.EndpointAlert.MinimumReminderInterval)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
restAPIURL = "https://api.pushover.net/1/messages.json"
|
||||
ApiURL = "https://api.pushover.net/1/messages.json"
|
||||
defaultPriority = 0
|
||||
)
|
||||
|
||||
@@ -76,9 +76,9 @@ func (cfg *Config) Validate() error {
|
||||
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
|
||||
return ErrInvalidPriority
|
||||
}
|
||||
if len(cfg.Device) > 25 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
if len(cfg.Device) > 25 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,9 +104,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if override.TTL > 0 {
|
||||
cfg.TTL = override.TTL
|
||||
}
|
||||
if len(override.Device) > 0 {
|
||||
cfg.Device = override.Device
|
||||
}
|
||||
if len(override.Device) > 0 {
|
||||
cfg.Device = override.Device
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
||||
@@ -130,7 +130,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||
request, err := http.NewRequest(http.MethodPost, ApiURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
212
alerting/provider/rocketchat/rocketchat.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package rocketchat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Rocket.Chat incoming webhook URL
|
||||
Channel string `yaml:"channel,omitempty"` // Optional channel override
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Channel) > 0 {
|
||||
cfg.Channel = override.Channel
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to rocketchat alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Text string `json:"text"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Color string `json:"color"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorIcon string `json:"author_icon"`
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), 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", ep.DisplayName(), alert.FailureThreshold)
|
||||
color = "#dd0000"
|
||||
}
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
body := Body{
|
||||
Text: "",
|
||||
Username: "Gatus",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Title: "🚨 Gatus Alert",
|
||||
Text: message + description,
|
||||
Color: color,
|
||||
AuthorName: "Gatus",
|
||||
AuthorIcon: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
if cfg.Channel != "" {
|
||||
body.Channel = cfg.Channel
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
Value: formattedConditionResults,
|
||||
Short: false,
|
||||
})
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
164
alerting/provider/rocketchat/rocketchat_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package rocketchat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-channel",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["username"] != "Gatus" {
|
||||
t.Errorf("expected username to be 'Gatus', got %v", body["username"])
|
||||
}
|
||||
attachments := body["attachments"].([]interface{})
|
||||
if len(attachments) != 1 {
|
||||
t.Errorf("expected 1 attachment, got %d", len(attachments))
|
||||
}
|
||||
attachment := attachments[0].(map[string]interface{})
|
||||
if attachment["color"] != "#dd0000" {
|
||||
t.Errorf("expected color to be '#dd0000', got %v", attachment["color"])
|
||||
}
|
||||
text := attachment["text"].(string)
|
||||
if !strings.Contains(text, "failed 3 time(s)") {
|
||||
t.Errorf("expected text to contain failure count, got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "triggered-with-channel",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["channel"] != "#alerts" {
|
||||
t.Errorf("expected channel to be '#alerts', got %v", body["channel"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
attachments := body["attachments"].([]interface{})
|
||||
attachment := attachments[0].(map[string]interface{})
|
||||
if attachment["color"] != "#36a64f" {
|
||||
t.Errorf("expected color to be '#36a64f', got %v", attachment["color"])
|
||||
}
|
||||
text := attachment["text"].(string)
|
||||
if !strings.Contains(text, "resolved") {
|
||||
t.Errorf("expected text to contain 'resolved', got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
248
alerting/provider/sendgrid/sendgrid.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package sendgrid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ApiURL = "https://api.sendgrid.com/v3/mail/send"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||
ErrFromNotSet = errors.New("from not set")
|
||||
ErrToNotSet = errors.New("to not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api-key"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.APIKey) == 0 {
|
||||
return ErrAPIKeyNotSet
|
||||
}
|
||||
if len(cfg.From) == 0 {
|
||||
return ErrFromNotSet
|
||||
}
|
||||
if len(cfg.To) == 0 {
|
||||
return ErrToNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.ClientConfig != nil {
|
||||
cfg.ClientConfig = override.ClientConfig
|
||||
}
|
||||
if len(override.APIKey) > 0 {
|
||||
cfg.APIKey = override.APIKey
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SendGrid
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||
payload := provider.buildSendGridPayload(cfg, subject, body)
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to sendgrid alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SendGridPayload struct {
|
||||
Personalizations []Personalization `json:"personalizations"`
|
||||
From Email `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
Content []Content `json:"content"`
|
||||
}
|
||||
|
||||
type Personalization struct {
|
||||
To []Email `json:"to"`
|
||||
}
|
||||
|
||||
type Email struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// buildSendGridPayload builds the SendGrid API payload
|
||||
func (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload {
|
||||
toEmails := strings.Split(cfg.To, ",")
|
||||
var recipients []Email
|
||||
for _, email := range toEmails {
|
||||
recipients = append(recipients, Email{Email: strings.TrimSpace(email)})
|
||||
}
|
||||
return SendGridPayload{
|
||||
Personalizations: []Personalization{
|
||||
{
|
||||
To: recipients,
|
||||
},
|
||||
},
|
||||
From: Email{
|
||||
Email: cfg.From,
|
||||
},
|
||||
Subject: subject,
|
||||
Content: []Content{
|
||||
{
|
||||
Type: "text/plain",
|
||||
Value: body,
|
||||
},
|
||||
{
|
||||
Type: "text/html",
|
||||
Value: strings.ReplaceAll(body, "\n", "<br>"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||
var subject, message string
|
||||
if resolved {
|
||||
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n\nCondition results:\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
var extraLabels string
|
||||
if len(ep.ExtraLabels) > 0 {
|
||||
extraLabels = "\n\nExtra labels:\n"
|
||||
for key, value := range ep.ExtraLabels {
|
||||
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
return subject, message + description + extraLabels + formattedConditionResults
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
517
alerting/provider/sendgrid/sendgrid_test.go
Normal file
@@ -0,0 +1,517 @@
|
||||
package sendgrid
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: "", From: "", To: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider with empty Group should not have been valid")
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride {
|
||||
t.Error("provider with empty Group should return ErrDuplicateGroupOverride")
|
||||
}
|
||||
providerWithDuplicateOverrideGroups := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "to1@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
{
|
||||
Config: Config{To: "to2@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithDuplicateOverrideGroups.Validate(); err == nil {
|
||||
t.Error("provider with duplicate group overrides should not have been valid")
|
||||
}
|
||||
if err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride {
|
||||
t.Error("provider with duplicate group overrides should return ErrDuplicateGroupOverride")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
providerWithValidMultipleOverrides := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "group1@example.com"},
|
||||
Group: "group1",
|
||||
},
|
||||
{
|
||||
Config: Config{To: "group2@example.com"},
|
||||
Group: "group2",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidMultipleOverrides.Validate(); err != nil {
|
||||
t.Error("provider with multiple valid overrides should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`))}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildSendGridPayload(t *testing.T) {
|
||||
provider := &AlertProvider{}
|
||||
cfg := &Config{
|
||||
From: "test@example.com",
|
||||
To: "to1@example.com,to2@example.com",
|
||||
}
|
||||
subject := "Test Subject"
|
||||
body := "Test Body\nWith new line"
|
||||
payload := provider.buildSendGridPayload(cfg, subject, body)
|
||||
if payload.Subject != subject {
|
||||
t.Errorf("expected subject to be %s, got %s", subject, payload.Subject)
|
||||
}
|
||||
if payload.From.Email != cfg.From {
|
||||
t.Errorf("expected from email to be %s, got %s", cfg.From, payload.From.Email)
|
||||
}
|
||||
if len(payload.Personalizations) != 1 {
|
||||
t.Errorf("expected 1 personalization, got %d", len(payload.Personalizations))
|
||||
}
|
||||
if len(payload.Personalizations[0].To) != 2 {
|
||||
t.Errorf("expected 2 recipients, got %d", len(payload.Personalizations[0].To))
|
||||
}
|
||||
if payload.Personalizations[0].To[0].Email != "to1@example.com" {
|
||||
t.Errorf("expected first recipient to be to1@example.com, got %s", payload.Personalizations[0].To[0].Email)
|
||||
}
|
||||
if payload.Personalizations[0].To[1].Email != "to2@example.com" {
|
||||
t.Errorf("expected second recipient to be to2@example.com, got %s", payload.Personalizations[0].To[1].Email)
|
||||
}
|
||||
if len(payload.Content) != 2 {
|
||||
t.Errorf("expected 2 content types, got %d", len(payload.Content))
|
||||
}
|
||||
if payload.Content[0].Type != "text/plain" {
|
||||
t.Errorf("expected first content type to be text/plain, got %s", payload.Content[0].Type)
|
||||
}
|
||||
if payload.Content[0].Value != body {
|
||||
t.Errorf("expected plain text content to be %s, got %s", body, payload.Content[0].Value)
|
||||
}
|
||||
if payload.Content[1].Type != "text/html" {
|
||||
t.Errorf("expected second content type to be text/html, got %s", payload.Content[1].Type)
|
||||
}
|
||||
expectedHTML := "Test Body<br>With new line"
|
||||
if payload.Content[1].Value != expectedHTML {
|
||||
t.Errorf("expected HTML content to be %s, got %s", expectedHTML, payload.Content[1].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
Endpoint *endpoint.Endpoint
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-extra-labels",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||
scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if subject != scenario.ExpectedSubject {
|
||||
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||
}
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{To: "to01@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{To: "group-to@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "group-to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{To: "group-to@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "SG.override", "to": "alert-to@example.com", "from": "alert-from@example.com"}},
|
||||
ExpectedOutput: Config{APIKey: "SG.override", From: "alert-from@example.com", To: "alert-to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-multiple-overrides-pick-correct-group",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{APIKey: "SG.group1", To: "group1@example.com"},
|
||||
},
|
||||
{
|
||||
Group: "group2",
|
||||
Config: Config{APIKey: "SG.group2", From: "group2@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group2",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.group2", From: "group2@example.com", To: "default@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-partial-override-hierarchy",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{From: "group@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}},
|
||||
ExpectedOutput: Config{APIKey: "SG.default", From: "group@example.com", To: "alert@example.com"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.APIKey != scenario.ExpectedOutput.APIKey {
|
||||
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if got.To != scenario.ExpectedOutput.To {
|
||||
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Config Config
|
||||
ExpectedError error
|
||||
}{
|
||||
{
|
||||
Name: "missing-api-key",
|
||||
Config: Config{APIKey: "", From: "test@example.com", To: "to@example.com"},
|
||||
ExpectedError: ErrAPIKeyNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-from",
|
||||
Config: Config{APIKey: "SG.test", From: "", To: "to@example.com"},
|
||||
ExpectedError: ErrFromNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-to",
|
||||
Config: Config{APIKey: "SG.test", From: "test@example.com", To: ""},
|
||||
ExpectedError: ErrToNotSet,
|
||||
},
|
||||
{
|
||||
Name: "valid-config",
|
||||
Config: Config{APIKey: "SG.test", From: "test@example.com", To: "to@example.com"},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
err := scenario.Config.Validate()
|
||||
if scenario.ExpectedError == nil && err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err == nil {
|
||||
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Merge(t *testing.T) {
|
||||
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
|
||||
override := Config{APIKey: "SG.override", To: "override@example.com"}
|
||||
config.Merge(&override)
|
||||
if config.APIKey != "SG.override" {
|
||||
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
|
||||
}
|
||||
if config.From != "from@example.com" {
|
||||
t.Errorf("expected From to remain from@example.com, got %s", config.From)
|
||||
}
|
||||
if config.To != "override@example.com" {
|
||||
t.Errorf("expected To to be override@example.com, got %s", config.To)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_MergeWithClientConfig(t *testing.T) {
|
||||
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
|
||||
override := Config{APIKey: "SG.override", ClientConfig: &client.Config{Timeout: 30000}}
|
||||
config.Merge(&override)
|
||||
if config.APIKey != "SG.override" {
|
||||
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
|
||||
}
|
||||
if config.ClientConfig == nil {
|
||||
t.Error("expected ClientConfig to be set")
|
||||
}
|
||||
if config.ClientConfig.Timeout != 30000 {
|
||||
t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout)
|
||||
}
|
||||
config2 := Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}}
|
||||
override2 := Config{APIKey: "SG.override2"}
|
||||
config2.Merge(&override2)
|
||||
if config2.ClientConfig.Timeout != 10000 {
|
||||
t.Errorf("expected ClientConfig.Timeout to remain 10000, got %d", config2.ClientConfig.Timeout)
|
||||
}
|
||||
}
|
||||
196
alerting/provider/signal/signal.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package signal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrApiURLNotSet = errors.New("api-url not set")
|
||||
ErrNumberNotSet = errors.New("number not set")
|
||||
ErrRecipientsNotSet = errors.New("recipients not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ApiURL string `yaml:"api-url"` // Signal API URL (e.g., signal-cli-rest-api instance)
|
||||
Number string `yaml:"number"` // Sender phone number
|
||||
Recipients []string `yaml:"recipients"` // List of recipient phone numbers
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ApiURL) == 0 {
|
||||
return ErrApiURLNotSet
|
||||
}
|
||||
if !strings.HasSuffix(cfg.ApiURL, "/v2/send") {
|
||||
cfg.ApiURL = cfg.ApiURL + "/v2/send"
|
||||
}
|
||||
if len(cfg.Number) == 0 {
|
||||
return ErrNumberNotSet
|
||||
}
|
||||
if len(cfg.Recipients) == 0 {
|
||||
return ErrRecipientsNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.ApiURL) > 0 {
|
||||
cfg.ApiURL = override.ApiURL
|
||||
}
|
||||
if len(override.Number) > 0 {
|
||||
cfg.Number = override.Number
|
||||
}
|
||||
if len(override.Recipients) > 0 {
|
||||
cfg.Recipients = override.Recipients
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Signal
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range cfg.Recipients {
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.ApiURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
return fmt.Errorf("call to signal alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Message string `json:"message"`
|
||||
Number string `json:"number"`
|
||||
Recipients []string `json:"recipients"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, recipient string) ([]byte, error) {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("🟢 RESOLVED: %s\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("🔴 ALERT: %s\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
message += "\n\nCondition results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
Message: message,
|
||||
Number: cfg.Number,
|
||||
Recipients: []string{recipient},
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
151
alerting/provider/signal/signal_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package signal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-api-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
expected: ErrApiURLNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-number",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Recipients: []string{"+0987654321"}}},
|
||||
expected: ErrNumberNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-recipients",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890"}},
|
||||
expected: ErrRecipientsNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321", "+1111111111"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/v2/send" {
|
||||
t.Errorf("expected path /v2/send, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["number"] != "+1234567890" {
|
||||
t.Errorf("expected number to be '+1234567890', got %v", body["number"])
|
||||
}
|
||||
recipients := body["recipients"].([]interface{})
|
||||
if len(recipients) != 1 {
|
||||
t.Errorf("expected 1 recipient per request, got %d", len(recipients))
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "ALERT") {
|
||||
t.Errorf("expected message to contain 'ALERT', got %s", message)
|
||||
}
|
||||
if !strings.Contains(message, "failed 3 time(s)") {
|
||||
t.Errorf("expected message to contain failure count, got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "RESOLVED") {
|
||||
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
184
alerting/provider/signl4/signl4.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package signl4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTeamSecretNotSet = errors.New("team-secret not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TeamSecret string `yaml:"team-secret"` // SIGNL4 team secret
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.TeamSecret) == 0 {
|
||||
return ErrTeamSecretNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.TeamSecret) > 0 {
|
||||
cfg.TeamSecret = override.TeamSecret
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SIGNL4
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
webhookURL := fmt.Sprintf("https://connect.signl4.com/webhook/%s", cfg.TeamSecret)
|
||||
request, err := http.NewRequest(http.MethodPost, webhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to signl4 alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Title string `json:"Title"`
|
||||
Message string `json:"Message"`
|
||||
XS4Service string `json:"X-S4-Service"`
|
||||
XS4Status string `json:"X-S4-Status"`
|
||||
XS4ExternalID string `json:"X-S4-ExternalID"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var title, message, status string
|
||||
if resolved {
|
||||
title = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
status = "resolved"
|
||||
} else {
|
||||
title = fmt.Sprintf("TRIGGERED: %s", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
status = "new"
|
||||
}
|
||||
var conditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
conditionResults = "\n\nCondition results:\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✓"
|
||||
} else {
|
||||
prefix = "✗"
|
||||
}
|
||||
conditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += "\n\nDescription: " + alertDescription
|
||||
}
|
||||
message += conditionResults
|
||||
body := Body{
|
||||
Title: title,
|
||||
Message: message,
|
||||
XS4Service: ep.DisplayName(),
|
||||
XS4Status: status,
|
||||
XS4ExternalID: fmt.Sprintf("gatus-%s", ep.Key()),
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package jetbrainsspace
|
||||
package signl4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
@@ -24,10 +24,9 @@ func TestAlertProvider_Validate(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
DefaultConfig: Config{Project: "foobar"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{ChannelID: "http://example.com"},
|
||||
Config: Config{TeamSecret: "team-secret-123"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
@@ -36,26 +35,21 @@ func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
DefaultConfig: Config{Project: "foobar"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{ChannelID: ""},
|
||||
Config: Config{TeamSecret: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
t.Error("provider team secret shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
Project: "foo",
|
||||
ChannelID: "bar",
|
||||
Token: "baz",
|
||||
},
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{ChannelID: "foobar"},
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
@@ -79,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -89,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -99,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -109,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -150,56 +144,72 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider AlertProvider
|
||||
Endpoint endpoint.Endpoint
|
||||
Alert alert.Alert
|
||||
NoConditions bool
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||
ExpectedBody: "{\"Title\":\"TRIGGERED: group/name\",\"Message\":\"An alert for group/name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||
ExpectedBody: "{\"Title\":\"RESOLVED: name\",\"Message\":\"An alert for name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||
ExpectedBody: "{\"Title\":\"RESOLVED: group/name\",\"Message\":\"An alert for group/name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
var conditionResults []*endpoint.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
body, err := scenario.Provider.buildRequestBody(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
ConditionResults: conditionResults,
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRequestBody returned an error: %v", err)
|
||||
}
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
@@ -231,67 +241,67 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
|
||||
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"team-secret": "team-secret-alert"}},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-alert"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -300,14 +310,8 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||
}
|
||||
if got.Project != scenario.ExpectedOutput.Project {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
|
||||
}
|
||||
if got.Token != scenario.ExpectedOutput.Token {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||
if got.TeamSecret != scenario.ExpectedOutput.TeamSecret {
|
||||
t.Errorf("expected team secret to be %s, got %s", scenario.ExpectedOutput.TeamSecret, got.TeamSecret)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
@@ -316,3 +320,73 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) {
|
||||
// Test case 1: Empty override should be ignored, default config should be used
|
||||
provider := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
}
|
||||
alertWithEmptyOverride := alert.Alert{
|
||||
ProviderOverride: map[string]any{"team-secret": ""},
|
||||
}
|
||||
cfg, err := provider.GetConfig("", &alertWithEmptyOverride)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.TeamSecret != "team-secret-123" {
|
||||
t.Errorf("expected team secret to remain default 'team-secret-123', got %s", cfg.TeamSecret)
|
||||
}
|
||||
|
||||
// Test case 2: Invalid default config with no valid override should fail
|
||||
providerWithInvalidDefault := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: ""},
|
||||
}
|
||||
alertWithEmptyOverride2 := alert.Alert{
|
||||
ProviderOverride: map[string]any{"team-secret": ""},
|
||||
}
|
||||
_, err = providerWithInvalidDefault.GetConfig("", &alertWithEmptyOverride2)
|
||||
if err == nil {
|
||||
t.Error("expected error due to invalid default config, got none")
|
||||
}
|
||||
if err != ErrTeamSecretNotSet {
|
||||
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) {
|
||||
providerWithDuplicateOverride := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{TeamSecret: "team-secret-override-1"},
|
||||
},
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{TeamSecret: "team-secret-override-2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithDuplicateOverride.Validate(); err == nil {
|
||||
t.Error("provider should not have been valid due to duplicate group override")
|
||||
}
|
||||
if err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride {
|
||||
t.Errorf("expected ErrDuplicateGroupOverride, got %v", providerWithDuplicateOverride.Validate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: ""},
|
||||
}
|
||||
alertWithEmptyOverride := alert.Alert{
|
||||
ProviderOverride: map[string]any{"team-secret": ""},
|
||||
}
|
||||
err := provider.ValidateOverrides("", &alertWithEmptyOverride)
|
||||
if err == nil {
|
||||
t.Error("expected error due to invalid default config, got none")
|
||||
}
|
||||
if err != ErrTeamSecretNotSet {
|
||||
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -34,6 +35,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
@@ -73,7 +77,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,7 +115,7 @@ type Field struct {
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -138,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
Text: "",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Title: ":helmet_with_white_cross: Gatus",
|
||||
Title: cfg.Title,
|
||||
Text: message + description,
|
||||
Short: false,
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(body.Attachments[0].Title) == 0 {
|
||||
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group-and-custom-title",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
|
||||
if err != nil {
|
||||
t.Fatal("couldn't get config:", err.Error())
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
cfg,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
|
||||
220
alerting/provider/splunk/splunk.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package splunk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHecURLNotSet = errors.New("hec-url not set")
|
||||
ErrHecTokenNotSet = errors.New("hec-token not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HecURL string `yaml:"hec-url"` // Splunk HEC (HTTP Event Collector) URL
|
||||
HecToken string `yaml:"hec-token"` // Splunk HEC token
|
||||
Source string `yaml:"source,omitempty"` // Event source
|
||||
SourceType string `yaml:"sourcetype,omitempty"` // Event source type
|
||||
Index string `yaml:"index,omitempty"` // Splunk index
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.HecURL) == 0 {
|
||||
return ErrHecURLNotSet
|
||||
}
|
||||
if len(cfg.HecToken) == 0 {
|
||||
return ErrHecTokenNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.HecURL) > 0 {
|
||||
cfg.HecURL = override.HecURL
|
||||
}
|
||||
if len(override.HecToken) > 0 {
|
||||
cfg.HecToken = override.HecToken
|
||||
}
|
||||
if len(override.Source) > 0 {
|
||||
cfg.Source = override.Source
|
||||
}
|
||||
if len(override.SourceType) > 0 {
|
||||
cfg.SourceType = override.SourceType
|
||||
}
|
||||
if len(override.Index) > 0 {
|
||||
cfg.Index = override.Index
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Splunk
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/services/collector/event", cfg.HecURL), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Splunk %s", cfg.HecToken))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to splunk alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Time int64 `json:"time"`
|
||||
Source string `json:"source,omitempty"`
|
||||
SourceType string `json:"sourcetype,omitempty"`
|
||||
Index string `json:"index,omitempty"`
|
||||
Event Event `json:"event"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
AlertType string `json:"alert_type"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Conditions []*endpoint.ConditionResult `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var alertType, status, message string
|
||||
if resolved {
|
||||
alertType = "resolved"
|
||||
status = "ok"
|
||||
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
alertType = "triggered"
|
||||
status = "critical"
|
||||
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
event := Event{
|
||||
AlertType: alertType,
|
||||
Endpoint: ep.DisplayName(),
|
||||
Group: ep.Group,
|
||||
Status: status,
|
||||
Message: message,
|
||||
Description: alert.GetDescription(),
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
event.Conditions = result.ConditionResults
|
||||
}
|
||||
body := Body{
|
||||
Time: time.Now().Unix(),
|
||||
Event: event,
|
||||
}
|
||||
// Set optional fields
|
||||
if cfg.Source != "" {
|
||||
body.Source = cfg.Source
|
||||
} else {
|
||||
body.Source = "gatus"
|
||||
}
|
||||
if cfg.SourceType != "" {
|
||||
body.SourceType = cfg.SourceType
|
||||
} else {
|
||||
body.SourceType = "gatus:alert"
|
||||
}
|
||||
if cfg.Index != "" {
|
||||
body.Index = cfg.Index
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
155
alerting/provider/splunk/splunk_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package splunk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-index",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-hec-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecToken: "token123"}},
|
||||
expected: ErrHecURLNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-hec-token",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088"}},
|
||||
expected: ErrHecTokenNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/services/collector/event" {
|
||||
t.Errorf("expected path /services/collector/event, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Splunk token123" {
|
||||
t.Errorf("expected Authorization header to be 'Splunk token123', got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["time"] == nil {
|
||||
t.Error("expected 'time' field in request body")
|
||||
}
|
||||
event := body["event"].(map[string]interface{})
|
||||
if event["alert_type"] != "triggered" {
|
||||
t.Errorf("expected alert_type to be 'triggered', got %v", event["alert_type"])
|
||||
}
|
||||
if event["status"] != "critical" {
|
||||
t.Errorf("expected status to be 'critical', got %v", event["status"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["index"] != "main" {
|
||||
t.Errorf("expected index to be 'main', got %v", body["index"])
|
||||
}
|
||||
event := body["event"].(map[string]interface{})
|
||||
if event["alert_type"] != "resolved" {
|
||||
t.Errorf("expected alert_type to be 'resolved', got %v", event["alert_type"])
|
||||
}
|
||||
if event["status"] != "ok" {
|
||||
t.Errorf("expected status to be 'ok', got %v", event["status"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
190
alerting/provider/squadcast/squadcast.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package squadcast
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Squadcast webhook URL
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Squadcast
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to squadcast alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EventID string `json:"event_id"`
|
||||
Status string `json:"status"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var message, status string
|
||||
eventID := fmt.Sprintf("gatus-%s", ep.Key())
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
|
||||
status = "resolve"
|
||||
} else {
|
||||
message = fmt.Sprintf("ALERT: %s", ep.DisplayName())
|
||||
status = "trigger"
|
||||
}
|
||||
description := fmt.Sprintf("Endpoint: %s\n", ep.DisplayName())
|
||||
if resolved {
|
||||
description += fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row\n", alert.SuccessThreshold)
|
||||
} else {
|
||||
description += fmt.Sprintf("Endpoint has failed %d time(s) in a row\n", alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description += fmt.Sprintf("\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
description += "\n\nCondition Results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
description += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
Message: message,
|
||||
Description: description,
|
||||
EventID: eventID,
|
||||
Status: status,
|
||||
Tags: map[string]string{
|
||||
"endpoint": ep.Name,
|
||||
"group": ep.Group,
|
||||
"source": "gatus",
|
||||
},
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
141
alerting/provider/squadcast/squadcast_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package squadcast
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["status"] != "trigger" {
|
||||
t.Errorf("expected status to be 'trigger', got %v", body["status"])
|
||||
}
|
||||
if body["event_id"] == nil {
|
||||
t.Error("expected 'event_id' field in request body")
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "ALERT") {
|
||||
t.Errorf("expected message to contain 'ALERT', got %s", message)
|
||||
}
|
||||
description := body["description"].(string)
|
||||
if !strings.Contains(description, "failed 3 time(s)") {
|
||||
t.Errorf("expected description to contain failure count, got %s", description)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["status"] != "resolve" {
|
||||
t.Errorf("expected status to be 'resolve', got %v", body["status"])
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "RESOLVED") {
|
||||
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,10 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
Value: conditionResult.Condition,
|
||||
})
|
||||
}
|
||||
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "**Description**: " + alertDescription
|
||||
}
|
||||
cardContent := AdaptiveCardBody{
|
||||
Type: "AdaptiveCard",
|
||||
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
|
||||
@@ -190,6 +193,11 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
Text: message,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: description,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "FactSet",
|
||||
Facts: facts,
|
||||
|
||||
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-1\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultApiUrl = "https://api.telegram.org"
|
||||
const ApiURL = "https://api.telegram.org"
|
||||
|
||||
var (
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
@@ -33,7 +33,7 @@ type Config struct {
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ApiUrl) == 0 {
|
||||
cfg.ApiUrl = defaultApiUrl
|
||||
cfg.ApiUrl = ApiURL
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
@@ -147,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
}
|
||||
var text string
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n%s \n%s", message, alert.GetDescription(), formattedConditionResults)
|
||||
} else {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
descriptionWithLink := "[link](https://example.org/)"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
@@ -137,14 +138,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\ndescription-2 \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
@@ -152,14 +153,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\ndescription-2 \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "send to topic",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
|
||||
},
|
||||
{
|
||||
Name: "triggered with link in description",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &descriptionWithLink, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n[link](https://example.org/) \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -29,8 +29,10 @@ type Config struct {
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// TODO in v6.0.0: Rename this to text-triggered
|
||||
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
|
||||
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
|
||||
// TODO in v6.0.0: Rename this to text-resolved
|
||||
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -113,13 +115,23 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
var message string
|
||||
if resolved {
|
||||
if len(cfg.TextTwilioResolved) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
|
||||
message = cfg.TextTwilioResolved
|
||||
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
|
||||
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
} else {
|
||||
if len(cfg.TextTwilioTriggered) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
|
||||
message = cfg.TextTwilioTriggered
|
||||
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
|
||||
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
|
||||
@@ -129,6 +129,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Resolved: true,
|
||||
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-old-placeholders",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: {endpoint} - {description}"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-new-placeholders",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: [ENDPOINT] - [ALERT_DESCRIPTION]"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-mixed-placeholders",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioResolved: "Resolved: {endpoint} and [ENDPOINT] - {description} and [ALERT_DESCRIPTION]"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "Body=Resolved%3A+endpoint-name+and+endpoint-name+-+description-2+and+description-2&From=3&To=4",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
|
||||
212
alerting/provider/vonage/vonage.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package vonage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ApiURL = "https://rest.nexmo.com/sms/json"
|
||||
|
||||
var (
|
||||
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||
ErrAPISecretNotSet = errors.New("api-secret not set")
|
||||
ErrFromNotSet = errors.New("from not set")
|
||||
ErrToNotSet = errors.New("to not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api-key"`
|
||||
APISecret string `yaml:"api-secret"`
|
||||
From string `yaml:"from"`
|
||||
To []string `yaml:"to"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.APIKey) == 0 {
|
||||
return ErrAPIKeyNotSet
|
||||
}
|
||||
if len(cfg.APISecret) == 0 {
|
||||
return ErrAPISecretNotSet
|
||||
}
|
||||
if len(cfg.From) == 0 {
|
||||
return ErrFromNotSet
|
||||
}
|
||||
if len(cfg.To) == 0 {
|
||||
return ErrToNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.APIKey) > 0 {
|
||||
cfg.APIKey = override.APIKey
|
||||
}
|
||||
if len(override.APISecret) > 0 {
|
||||
cfg.APISecret = override.APISecret
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Vonage
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := provider.buildMessage(cfg, ep, alert, result, resolved)
|
||||
|
||||
// Send SMS to each recipient
|
||||
for _, recipient := range cfg.To {
|
||||
if err := provider.sendSMS(cfg, recipient, message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSMS sends an individual SMS message
|
||||
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
|
||||
data := url.Values{}
|
||||
data.Set("api_key", cfg.APIKey)
|
||||
data.Set("api_secret", cfg.APISecret)
|
||||
data.Set("from", cfg.From)
|
||||
data.Set("to", to)
|
||||
data.Set("text", message)
|
||||
request, err := http.NewRequest(http.MethodPost, ApiURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
// Read response body once and use it for both error handling and JSON processing
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("call to vonage alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
// Check response for errors in messages array
|
||||
var vonageResponse Response
|
||||
if err := json.Unmarshal(body, &vonageResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
// Check if any message failed
|
||||
for _, msg := range vonageResponse.Messages {
|
||||
if msg.Status != "0" {
|
||||
return fmt.Errorf("vonage SMS failed with status %s: %s", msg.Status, msg.ErrorText)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
MessageCount string `json:"message-count"`
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
To string `json:"to"`
|
||||
MessageID string `json:"message-id"`
|
||||
Status string `json:"status"`
|
||||
ErrorText string `json:"error-text"`
|
||||
RemainingBalance string `json:"remaining-balance"`
|
||||
MessagePrice string `json:"message-price"`
|
||||
Network string `json:"network"`
|
||||
}
|
||||
|
||||
// buildMessage builds the SMS message content
|
||||
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
if resolved {
|
||||
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
} else {
|
||||
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
546
alerting/provider/vonage/vonage_test.go
Normal file
@@ -0,0 +1,546 @@
|
||||
package vonage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestVonageAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
validProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "override-key",
|
||||
APISecret: "override-secret",
|
||||
From: "Override",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsNotValidWithInvalidOverrideGroup(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "",
|
||||
Config: Config{
|
||||
APIKey: "override-key",
|
||||
APISecret: "override-secret",
|
||||
From: "Override",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsNotValidWithDuplicateOverrideGroup(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "override-key1",
|
||||
APISecret: "override-secret1",
|
||||
From: "Override1",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "override-key2",
|
||||
APISecret: "override-secret2",
|
||||
From: "Override2",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsValidWithInvalidFrom(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsValidWithInvalidTo(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.50","message-price":"0.10","network":"12345"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error-status-code",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error-vonage-response",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"","status":"2","error-text":"Missing from param"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.40","message-price":"0.10","network":"12345"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "multiple-recipients",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890", "+0987654321"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.30","message-price":"0.10","network":"12345"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildMessage(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedMessage string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
message := scenario.Provider.buildMessage(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if message != scenario.ExpectedMessage {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "group-override-key",
|
||||
APISecret: "group-override-secret",
|
||||
From: "GroupOverride",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "group-override-key",
|
||||
APISecret: "group-override-secret",
|
||||
From: "GroupOverride",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-partial",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"api-key": "override-key",
|
||||
"api-secret": "override-secret",
|
||||
"from": "Override",
|
||||
"to": []string{"+9876543210"},
|
||||
}},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "override-key",
|
||||
APISecret: "override-secret",
|
||||
From: "Override",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-both-group-and-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "group-override-key",
|
||||
From: "GroupOverride",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"api-secret": "alert-override-secret",
|
||||
"to": []string{"+9876543210"},
|
||||
}},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "group-override-key",
|
||||
APISecret: "alert-override-secret",
|
||||
From: "GroupOverride",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-no-match",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "different-group",
|
||||
Config: Config{
|
||||
APIKey: "group-override-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got:", err.Error())
|
||||
}
|
||||
if got.APIKey != scenario.ExpectedOutput.APIKey {
|
||||
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
|
||||
}
|
||||
if got.APISecret != scenario.ExpectedOutput.APISecret {
|
||||
t.Errorf("expected APISecret to be %s, got %s", scenario.ExpectedOutput.APISecret, got.APISecret)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if len(got.To) != len(scenario.ExpectedOutput.To) {
|
||||
t.Errorf("expected To to have length %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
|
||||
} else {
|
||||
for i, to := range got.To {
|
||||
if to != scenario.ExpectedOutput.To[i] {
|
||||
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
171
alerting/provider/webex/webex.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package webex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Webex Teams webhook URL
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Webex
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to webex alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
RoomID string `json:"roomId,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("✅ **RESOLVED**: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("🚨 **ALERT**: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += fmt.Sprintf("\n\n**Description**: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
message += "\n\n**Condition Results:**"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
message += fmt.Sprintf("\n- %s `%s`", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
Markdown: message,
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
134
alerting/provider/webex/webex_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package webex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["markdown"] == nil {
|
||||
t.Error("expected 'markdown' field in request body")
|
||||
}
|
||||
markdown := body["markdown"].(string)
|
||||
if !strings.Contains(markdown, "ALERT") {
|
||||
t.Errorf("expected markdown to contain 'ALERT', got %s", markdown)
|
||||
}
|
||||
if !strings.Contains(markdown, "failed 3 time(s)") {
|
||||
t.Errorf("expected markdown to contain failure count, got %s", markdown)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
markdown := body["markdown"].(string)
|
||||
if !strings.Contains(markdown, "RESOLVED") {
|
||||
t.Errorf("expected markdown to contain 'RESOLVED', got %s", markdown)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
197
alerting/provider/zapier/zapier.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package zapier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Zapier webhook URL
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Zapier
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to zapier alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
AlertType string `json:"alert_type"`
|
||||
Status string `json:"status"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
SuccessThreshold int `json:"success_threshold,omitempty"`
|
||||
FailureThreshold int `json:"failure_threshold,omitempty"`
|
||||
ConditionResults []*endpoint.ConditionResult `json:"condition_results,omitempty"`
|
||||
TotalConditions int `json:"total_conditions"`
|
||||
PassedConditions int `json:"passed_conditions"`
|
||||
FailedConditions int `json:"failed_conditions"`
|
||||
}
|
||||
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var alertType, status, message string
|
||||
var successThreshold, failureThreshold int
|
||||
if resolved {
|
||||
alertType = "resolved"
|
||||
status = "ok"
|
||||
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
successThreshold = alert.SuccessThreshold
|
||||
} else {
|
||||
alertType = "triggered"
|
||||
status = "critical"
|
||||
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
failureThreshold = alert.FailureThreshold
|
||||
}
|
||||
// Process condition results
|
||||
passedConditions := 0
|
||||
failedConditions := 0
|
||||
for _, cr := range result.ConditionResults {
|
||||
if cr.Success {
|
||||
passedConditions++
|
||||
} else {
|
||||
failedConditions++
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
AlertType: alertType,
|
||||
Status: status,
|
||||
Endpoint: ep.DisplayName(),
|
||||
Group: ep.Group,
|
||||
Message: message,
|
||||
Description: alert.GetDescription(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
SuccessThreshold: successThreshold,
|
||||
FailureThreshold: failureThreshold,
|
||||
ConditionResults: result.ConditionResults,
|
||||
TotalConditions: len(result.ConditionResults),
|
||||
PassedConditions: passedConditions,
|
||||
FailedConditions: failedConditions,
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
162
alerting/provider/zapier/zapier_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package zapier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "hooks.zapier.com" {
|
||||
t.Errorf("expected host hooks.zapier.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/hooks/catch/123456/abcdef/" {
|
||||
t.Errorf("expected path /hooks/catch/123456/abcdef/, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["alert_type"] != "triggered" {
|
||||
t.Errorf("expected alert_type to be 'triggered', got %v", body["alert_type"])
|
||||
}
|
||||
if body["status"] != "critical" {
|
||||
t.Errorf("expected status to be 'critical', got %v", body["status"])
|
||||
}
|
||||
if body["endpoint"] != "endpoint-name" {
|
||||
t.Errorf("expected endpoint to be 'endpoint-name', got %v", body["endpoint"])
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "Alert") {
|
||||
t.Errorf("expected message to contain 'Alert', got %s", message)
|
||||
}
|
||||
if !strings.Contains(message, "failed 3 time(s)") {
|
||||
t.Errorf("expected message to contain failure count, got %s", message)
|
||||
}
|
||||
if body["description"] != firstDescription {
|
||||
t.Errorf("expected description to be '%s', got %v", firstDescription, body["description"])
|
||||
}
|
||||
conditionResults := body["condition_results"].([]interface{})
|
||||
if len(conditionResults) != 2 {
|
||||
t.Errorf("expected 2 condition results, got %d", len(conditionResults))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["alert_type"] != "resolved" {
|
||||
t.Errorf("expected alert_type to be 'resolved', got %v", body["alert_type"])
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("expected status to be 'ok', got %v", body["status"])
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "resolved") {
|
||||
t.Errorf("expected message to contain 'resolved', got %s", message)
|
||||
}
|
||||
if body["description"] != secondDescription {
|
||||
t.Errorf("expected description to be '%s', got %v", secondDescription, body["description"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
},
|
||||
ReadBufferSize: cfg.Web.ReadBufferSize,
|
||||
Network: fiber.NetworkTCP,
|
||||
Immutable: true, // If not enabled, will cause issues due to fiber's zero allocation. See #1268 and https://docs.gofiber.io/#zero-allocation
|
||||
})
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
app.Use(cors.New(cors.Config{
|
||||
@@ -75,7 +76,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
// UNPROTECTED ROUTES //
|
||||
////////////////////////
|
||||
unprotectedAPIRouter := apiRouter.Group("/")
|
||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
|
||||
@@ -83,11 +84,13 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/history", ResponseTimeHistory)
|
||||
// This endpoint requires authz with bearer token, so technically it is protected
|
||||
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||
// SPA
|
||||
app.Get("/", SinglePageApplication(cfg.UI))
|
||||
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
|
||||
app.Get("/endpoints/:key", SinglePageApplication(cfg.UI))
|
||||
app.Get("/suites/:key", SinglePageApplication(cfg.UI))
|
||||
// Health endpoint
|
||||
healthHandler := health.Handler().WithJSON(true)
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
@@ -127,5 +130,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
}
|
||||
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
|
||||
protectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestBadge(t *testing.T) {
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
@@ -284,8 +284,8 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().InsertEndpointResult(&firstTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().InsertEndpointResult(&secondTestEndpoint, &testSuccessfulResult)
|
||||
|
||||
scenarios := []struct {
|
||||
Key string
|
||||
|
||||
60
api/chart.go
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResponseTimeHistory(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "30d":
|
||||
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
|
||||
case "7d":
|
||||
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||
}
|
||||
endpointKey, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if len(hourlyAverageResponseTime) == 0 {
|
||||
return c.Status(200).JSON(map[string]interface{}{
|
||||
"timestamps": []int64{},
|
||||
"values": []int{},
|
||||
})
|
||||
}
|
||||
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
|
||||
earliestTimestamp := int64(0)
|
||||
for hourlyTimestamp := range hourlyAverageResponseTime {
|
||||
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
|
||||
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
|
||||
earliestTimestamp = hourlyTimestamp
|
||||
}
|
||||
}
|
||||
for earliestTimestamp > from.Unix() {
|
||||
earliestTimestamp -= int64(time.Hour.Seconds())
|
||||
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
|
||||
}
|
||||
sort.Ints(hourlyTimestamps)
|
||||
timestamps := make([]int64, 0, len(hourlyTimestamps))
|
||||
values := make([]int, 0, len(hourlyTimestamps))
|
||||
for _, hourlyTimestamp := range hourlyTimestamps {
|
||||
timestamp := int64(hourlyTimestamp)
|
||||
averageResponseTime := hourlyAverageResponseTime[timestamp]
|
||||
timestamps = append(timestamps, timestamp*1000)
|
||||
values = append(values, averageResponseTime)
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(map[string]interface{}{
|
||||
"timestamps": timestamps,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestResponseTimeChart(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
@@ -81,3 +81,69 @@ func TestResponseTimeChart(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseTimeHistory(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "history-response-time-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/history",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/history",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-30d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/30d/history",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/history",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "history-response-time-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d/history",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
securityConfig *security.Config
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
@@ -18,8 +21,24 @@ func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
hasOIDC = handler.securityConfig.OIDC != nil
|
||||
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
||||
}
|
||||
// Return the config
|
||||
|
||||
// Prepare response with announcements
|
||||
response := map[string]interface{}{
|
||||
"oidc": hasOIDC,
|
||||
"authenticated": isAuthenticated,
|
||||
}
|
||||
// Add announcements if available, otherwise use empty slice
|
||||
if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {
|
||||
response["announcements"] = handler.config.Announcements
|
||||
} else {
|
||||
response["announcements"] = []interface{}{}
|
||||
}
|
||||
|
||||
// Return the config as JSON
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).
|
||||
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
|
||||
}
|
||||
return c.Status(200).Send(responseBytes)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != `{"oidc":true,"authenticated":false}` {
|
||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
|
||||
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
|
||||
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ func TestEndpointStatus(t *testing.T) {
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
@@ -156,8 +156,8 @@ func TestEndpointStatuses(t *testing.T) {
|
||||
defer cache.Clear()
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
store.Get().Insert(&testEndpoint, firstResult)
|
||||
store.Get().Insert(&testEndpoint, secondResult)
|
||||
store.Get().InsertEndpointResult(&testEndpoint, firstResult)
|
||||
store.Get().InsertEndpointResult(&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{}
|
||||
|
||||
@@ -56,11 +56,11 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
}
|
||||
result.Duration = parsedDuration
|
||||
}
|
||||
if !result.Success && c.Query("error") != "" {
|
||||
result.Errors = append(result.Errors, c.Query("error"))
|
||||
if errorFromQuery := c.Query("error"); !result.Success && len(errorFromQuery) > 0 {
|
||||
result.AddError(errorFromQuery)
|
||||
}
|
||||
convertedEndpoint := externalEndpoint.ToEndpoint()
|
||||
if err := store.Get().Insert(convertedEndpoint, result); err != nil {
|
||||
if err := store.Get().InsertEndpointResult(convertedEndpoint, result); err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
|
||||
inEndpointMaintenanceWindow := false
|
||||
for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {
|
||||
if maintenanceWindow.IsUnderMaintenance() {
|
||||
logr.Debug("[api.CreateExternalEndpointResult] Under endpoint maintenance window")
|
||||
inEndpointMaintenanceWindow = true
|
||||
}
|
||||
}
|
||||
// Check if an alert should be triggered or resolved
|
||||
if !cfg.Maintenance.IsUnderMaintenance() {
|
||||
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
|
||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||
} else {
|
||||
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
|
||||
}
|
||||
if cfg.Metrics {
|
||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
||||
|
||||
11
api/raw.go
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestRawDataEndpoint(t *testing.T) {
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
|
||||